mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:50:14 +00:00
Merge origin/main into fix/document-properties-layout
Conflict in parity/acrobat-features.toml: main moved the integration tests into tests/it/, so the evidence paths take main's side and this branch's two dialog_chrome tests are added on the new paths; the notes keep this branch's wording. The custom-properties entry is now partial through the tools, but the dialog still has no Custom tab, so the "No Custom tab" note stays true. Also fixes a break the merge could not show as a conflict: the cursor test's form.pdf fixture is ../data/form.pdf from tests/it/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -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)"
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
|
||||
with:
|
||||
command: check
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
+43
-28
@@ -4,31 +4,37 @@
|
||||
|
||||
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 (272)
|
||||
## In this repository (288)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/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/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `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 |
|
||||
@@ -224,6 +230,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
|
||||
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
|
||||
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
@@ -268,20 +275,29 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) |
|
||||
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
|
||||
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make |
|
||||
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make |
|
||||
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
|
||||
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
## Compiled in through dependencies (5)
|
||||
|
||||
| Crate | File | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|---|
|
||||
@@ -289,7 +305,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `hayro-cmap` 0.1.0 | `assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) (non-visual data, AGENTS.md §1.1) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
|
||||
## Downloaded at build time (22)
|
||||
|
||||
+219
-56
@@ -2,12 +2,12 @@
|
||||
path = "crates/ui-egui/src/i18n/el.tsv"
|
||||
title = "Greek interface translation catalog"
|
||||
author = "Camthalion77 and contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
|
||||
sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
@@ -25,222 +25,286 @@ sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
|
||||
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
|
||||
title = "Simplified Chinese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223"
|
||||
sha256 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
title = "Traditional Chinese (Taiwan) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547"
|
||||
sha256 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
title = "Czech interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536"
|
||||
sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
title = "Brazilian Portuguese interface translation catalog"
|
||||
author = "André Cardoso (andrecards), PdfCraft contributors"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7"
|
||||
sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
title = "Spanish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679"
|
||||
sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
title = "French interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a"
|
||||
sha256 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
title = "German interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69"
|
||||
sha256 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lo.tsv"
|
||||
title = "Lao interface translation catalog"
|
||||
author = "@bounkirdni-2025"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
title = "Telugu interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
|
||||
sha256 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
title = "Italian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5"
|
||||
sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
title = "Polish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
|
||||
sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
title = "Russian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363"
|
||||
sha256 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
title = "Hungarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643"
|
||||
sha256 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
title = "Bulgarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e"
|
||||
sha256 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
title = "Kazakh interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578"
|
||||
sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
title = "Japanese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf"
|
||||
sha256 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
title = "Ukrainian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2"
|
||||
sha256 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
title = "Arabic interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25"
|
||||
sha256 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ckb.tsv"
|
||||
title = "Central Kurdish (Sorani) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
title = "Turkish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339"
|
||||
sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
title = "Lithuanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "62151d79be5089d964876b6569d490ee3fdacef4388684b574be9627b1354e92"
|
||||
|
||||
sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/vi.tsv"
|
||||
title = "Vietnamese interface translation catalog"
|
||||
path = "crates/ui-egui/src/i18n/hi.tsv"
|
||||
title = "Hindi interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "f9002d56e44e49429e0cdeabb10525f79f32831b94233f3c0a8a0ecb202850d5"
|
||||
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/id.tsv"
|
||||
title = "Indonesian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ko.tsv"
|
||||
title = "Korean interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ro.tsv"
|
||||
title = "Romanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8"
|
||||
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fi.tsv"
|
||||
title = "Finnish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -2436,14 +2500,13 @@ kind = "font"
|
||||
usage = "egui fallback fonts (eframe feature default_fonts)"
|
||||
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
|
||||
|
||||
[[bundled]]
|
||||
crate = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
path = "assets/cmaps.brotli"
|
||||
[[asset]]
|
||||
path = "vendor/hayro-cmap/assets/cmaps.brotli"
|
||||
title = "Adobe CMap resources (predefined CJK CMaps)"
|
||||
author = "Adobe"
|
||||
source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap)"
|
||||
source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/)"
|
||||
licence = "BSD-3-Clause"
|
||||
licence_file = "vendor/hayro-cmap/assets/LICENSE.txt"
|
||||
kind = "data"
|
||||
adobe_data = true
|
||||
usage = "Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1"
|
||||
@@ -3214,7 +3277,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "document"
|
||||
usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)"
|
||||
usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)"
|
||||
sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040"
|
||||
|
||||
[[asset]]
|
||||
@@ -3247,7 +3310,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "document"
|
||||
usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make"
|
||||
usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make"
|
||||
sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c"
|
||||
|
||||
[[asset]]
|
||||
@@ -3340,7 +3403,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "document"
|
||||
usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
|
||||
usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
|
||||
sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b"
|
||||
|
||||
[[asset]]
|
||||
@@ -3354,18 +3417,30 @@ 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
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 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c"
|
||||
sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f"
|
||||
|
||||
[[asset]]
|
||||
|
||||
path = "vendor/vello_common/assets/probe.png"
|
||||
title = "vello_common probe reference image (rendered from probe.rgba)"
|
||||
author = "Linebender vello contributors"
|
||||
@@ -3375,3 +3450,91 @@ licence_file = "vendor/vello_common/LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
|
||||
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/nb.tsv"
|
||||
title = "Norwegian Bokmål interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/superscript.svg"
|
||||
title = "Lucide icon \"superscript\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/subscript.svg"
|
||||
title = "Lucide icon \"subscript\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/pilcrow.svg"
|
||||
title = "Lucide icon \"pilcrow\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/move-horizontal.svg"
|
||||
title = "Lucide icon \"move-horizontal\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sq.tsv"
|
||||
title = "Albanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/th.tsv"
|
||||
title = "Thai interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ca.tsv"
|
||||
title = "Catalan interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593"
|
||||
|
||||
@@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- No `unsafe` (`unsafe_code = "forbid"`).
|
||||
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
|
||||
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
|
||||
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
|
||||
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
|
||||
@@ -38,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
|
||||
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
|
||||
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
|
||||
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search "<terms>"` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor <commit> origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
|
||||
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
|
||||
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
|
||||
|
||||
|
||||
Generated
+67
-48
@@ -1142,6 +1142,11 @@ 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"
|
||||
@@ -1882,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2544,8 +2549,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
@@ -3933,8 +3936,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3958,7 +3962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3970,7 +3974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -3981,7 +3985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3992,7 +3996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4006,6 +4010,7 @@ dependencies = [
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
@@ -4013,7 +4018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4021,7 +4026,9 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-preflight",
|
||||
"pdfcraft-render",
|
||||
"png 0.18.1",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
@@ -4029,19 +4036,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
"pdfcraft-crypt",
|
||||
"pdfcraft-filters",
|
||||
@@ -4054,7 +4062,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4070,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4082,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4097,9 +4105,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"icu_properties",
|
||||
"idna",
|
||||
"image",
|
||||
@@ -4125,24 +4134,26 @@ dependencies = [
|
||||
"pdfcraft-redact",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-sign",
|
||||
"pdfcraft-testkit",
|
||||
"pdfcraft-xfa",
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4152,7 +4163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4162,30 +4173,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4198,14 +4211,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4215,7 +4228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4228,11 +4241,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4243,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4253,27 +4266,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4289,7 +4305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4308,7 +4324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4316,6 +4332,7 @@ dependencies = [
|
||||
"bp256",
|
||||
"bp384",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"crypto-bigint",
|
||||
"des",
|
||||
"ecdsa",
|
||||
@@ -4342,15 +4359,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"eframe",
|
||||
@@ -4390,7 +4408,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4402,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4416,7 +4434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
@@ -4924,7 +4942,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5474,7 +5492,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5544,7 +5562,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5555,9 +5573,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -6421,7 +6439,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7296,9 +7314,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -7504,7 +7522,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8024,6 +8042,7 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"lopdf",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-oracle",
|
||||
|
||||
+39
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0",
|
||||
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 = "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.8.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.8.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.8.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.8.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.8.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.8.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.8.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.8.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.8.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.8.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.8.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.8.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.8.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.8.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.8.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.8.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.8.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.8.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.8.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.8.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.11.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.11.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.11.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.11.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.11.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.11.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.11.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.11.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.11.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.11.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.11.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.11.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.11.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.11.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.11.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.11.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.11.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.11.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.11.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.11.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.11.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -66,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
kurbo = "0.13"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
|
||||
# testing
|
||||
proptest = "1"
|
||||
@@ -110,6 +114,7 @@ codegen-units = 1
|
||||
hayro-interpret = { path = "vendor/hayro-interpret" }
|
||||
hayro = { path = "vendor/hayro" }
|
||||
hayro-syntax = { path = "vendor/hayro-syntax" }
|
||||
hayro-cmap = { path = "vendor/hayro-cmap" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
|
||||
@@ -8,19 +8,26 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a teal tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">PdfCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563">
|
||||
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a">
|
||||
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563?style=flat-square">
|
||||
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a?style=flat-square">
|
||||
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_pdfcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/pdfcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -137,6 +144,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
|
||||
|
||||
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
|
||||
|
||||
In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/pdfcraft-find.png" alt="The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading"></td>
|
||||
@@ -417,16 +426,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).
|
||||
|
||||
@@ -581,8 +590,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>
|
||||
|
||||
+176
-6
@@ -2,10 +2,36 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
|
||||
> **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-15 · **Last updated:** 2026-10-16 · **Change:** minor (M14: Czech catalog completed and reviewed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -26,6 +52,13 @@
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
|
||||
|
||||
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
|
||||
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
|
||||
@@ -117,18 +150,18 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
|
||||
|
||||
## Languages
|
||||
|
||||
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
|
||||
union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
|
||||
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
|
||||
| Spanish | `es` | 95.5% | partial |
|
||||
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 100% | partial (layout not mirrored) |
|
||||
| French | `fr` | 99.3% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
|
||||
| Indonesian | `id` | 0% | none |
|
||||
| Japanese | `ja` | 99.1% | partial |
|
||||
| German | `de` | 99.3% | partial |
|
||||
@@ -136,8 +169,8 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari
|
||||
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
|
||||
(23 interface languages including English).
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
|
||||
(24 interface languages including English).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -156,15 +189,116 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
|
||||
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
|
||||
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
|
||||
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
|
||||
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
|
||||
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
|
||||
|
||||
- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
|
||||
|
||||
- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
|
||||
- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
|
||||
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
|
||||
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
|
||||
- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
|
||||
- **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged.
|
||||
- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged.
|
||||
- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
|
||||
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
|
||||
- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
|
||||
|
||||
- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
|
||||
- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
|
||||
- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change.
|
||||
- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged.
|
||||
- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged.
|
||||
- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
|
||||
- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M3, PR #3101 review):** Gap changes in Organize Pages preserve document scroll position; menu and control changes apply to both comparison panes without changing unrelated tabs. Three synthetic UI regressions were observed failing before the fixes. Readiness estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M3, PR #3101):** Optional gapless continuous page display with persisted defaults and UI control support; both new labels translated in all 22 catalogs. Rebased onto current main, preserving navigation and rendering fixes. Readiness estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
|
||||
- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
|
||||
- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
|
||||
- **2026-10-18 (M2, #3086):** Text extraction estimates letter tracking without repeated punctuation gaps so dotted TOC leaders do not swallow word spaces. Synthetic PDF regressions cover leader lengths, phrase search and selection; strongly tracked text stays intact. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged.
|
||||
- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged.
|
||||
- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged.
|
||||
- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
|
||||
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
|
||||
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
|
||||
- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
|
||||
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
|
||||
- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
|
||||
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
|
||||
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
|
||||
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
|
||||
- **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-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
|
||||
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
|
||||
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
|
||||
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
|
||||
- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
|
||||
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
|
||||
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
|
||||
- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
|
||||
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
|
||||
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
|
||||
- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged.
|
||||
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
|
||||
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
|
||||
- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
|
||||
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
|
||||
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
|
||||
- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
|
||||
- **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help).
|
||||
- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged.
|
||||
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
|
||||
|
||||
- **2026-10-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.
|
||||
@@ -173,6 +307,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **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.
|
||||
@@ -201,6 +336,13 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
|
||||
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
|
||||
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
|
||||
- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
|
||||
- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers.
|
||||
- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and estimates unchanged.
|
||||
|
||||
- **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged.
|
||||
- **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending.
|
||||
- **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged.
|
||||
- **2026-10-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.
|
||||
@@ -283,6 +425,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.
|
||||
@@ -488,8 +631,27 @@ 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-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
|
||||
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
|
||||
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
|
||||
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
|
||||
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
|
||||
| 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. |
|
||||
@@ -510,7 +672,15 @@ 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.
|
||||
- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
|
||||
|
||||
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
|
||||
|
||||
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
|
||||
|
||||
@@ -18,10 +18,15 @@ 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"
|
||||
|
||||
[dev-dependencies]
|
||||
# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
|
||||
png = "0.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
|
||||
# Already in Cargo.lock through the GUI crates.
|
||||
|
||||
+119
-32
@@ -57,6 +57,7 @@ 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]
|
||||
@@ -175,7 +176,12 @@ fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
|
||||
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
|
||||
// reports them; the page geometry is read only when there is something to place.
|
||||
let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
|
||||
let info = if findings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
|
||||
};
|
||||
let items: Vec<serde_json::Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
@@ -298,29 +304,47 @@ 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())?;
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let pdf_pages = info.pages.len();
|
||||
let acroform_fields = info.fields.len();
|
||||
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
|
||||
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,
|
||||
});
|
||||
@@ -348,25 +372,31 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
let password = flag(args, "--password");
|
||||
let bytes = read(path)?;
|
||||
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
|
||||
// `inspect` why instead, so a protected file fails the same way `info` does.
|
||||
if r.page_count() == 0 {
|
||||
let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
|
||||
if !info.pages.is_empty() {
|
||||
return Err("text: the document could not be parsed".into());
|
||||
}
|
||||
}
|
||||
let mut bytes = read(path)?;
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
// Worked out from the file as read: `bytes` may become the session's display copy below.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
|
||||
// permissions password, a document that doesn't allow copying gives no text.
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
|
||||
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: render_password.as_deref().map(Arc::from), ..Default::default() });
|
||||
// A document the renderer could not open (wrong or missing password, unparseable file) has
|
||||
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
|
||||
// `inspect` why instead, so a protected file fails the same way `info` does.
|
||||
if r.page_count() == 0 {
|
||||
let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
|
||||
if !info.pages.is_empty() {
|
||||
return Err("text: the document could not be parsed".into());
|
||||
}
|
||||
}
|
||||
let pages: Vec<usize> = match selected_page {
|
||||
Some(page) => vec![page],
|
||||
@@ -458,17 +488,31 @@ fn edit(args: &[String]) -> Result<(), CliError> {
|
||||
"--password" => password = Some(value),
|
||||
"--rotate" => {
|
||||
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
|
||||
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
|
||||
let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
|
||||
// The same rule `rotate_pages` enforces, checked here so a bad angle is named
|
||||
// with its option before any file is opened.
|
||||
if degrees % 90 != 0 {
|
||||
return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
|
||||
}
|
||||
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
|
||||
}
|
||||
"--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
|
||||
"--move" => {
|
||||
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
|
||||
let to: usize = to.parse().map_err(|_| "bad target")?;
|
||||
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
|
||||
let target: usize = to
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|target| *target > 0)
|
||||
.ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
|
||||
edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
|
||||
}
|
||||
"--insert-blank" => {
|
||||
let at: usize = value.parse().map_err(|_| "bad position")?;
|
||||
edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
|
||||
let position: usize = value
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|position| *position > 0)
|
||||
.ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
|
||||
edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 });
|
||||
}
|
||||
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
|
||||
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
|
||||
@@ -550,7 +594,16 @@ fn split(args: &[String]) -> Result<(), CliError> {
|
||||
use pdfcraft_engine::SplitBy;
|
||||
let path = *positional(args).first().ok_or("split: missing file")?;
|
||||
let by = match (flag(args, "--every"), flag(args, "--before")) {
|
||||
(Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
|
||||
(Some(n), None) => {
|
||||
// A count the splitter cannot hold is refused here, before the file is opened,
|
||||
// rather than silently clamped to 1 further down.
|
||||
let count = n
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|count| *count > 0)
|
||||
.ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
|
||||
SplitBy::PageCount(count)
|
||||
}
|
||||
(None, Some(list)) => SplitBy::Before(page_list(list)?),
|
||||
_ => return Err("split: give either --every N or --before PAGES".into()),
|
||||
};
|
||||
@@ -570,12 +623,25 @@ fn split(args: &[String]) -> Result<(), CliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
|
||||
/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
|
||||
/// internal message (and in `check`, passed on as text, they would turn every file into a
|
||||
/// child that printed nothing), so the CLI names the option and the value instead.
|
||||
fn parse_dpi(value: &str) -> Result<f32, String> {
|
||||
value
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
.filter(|dpi| dpi.is_finite() && *dpi > 0.0)
|
||||
.ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("render", args, RENDER_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("render: missing file")?;
|
||||
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
|
||||
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
|
||||
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
|
||||
let page: usize =
|
||||
flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
|
||||
let page_index = page - 1;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
|
||||
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
|
||||
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
|
||||
let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
|
||||
@@ -588,16 +654,26 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
// 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 mut r = PageRenderer::new(
|
||||
pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")),
|
||||
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
|
||||
);
|
||||
let password = flag(args, "--password");
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
|
||||
// copy take either.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
|
||||
session.get(id).ok_or("render: the document could not be opened")?.display.clone()
|
||||
} else {
|
||||
pdfcraft_engine::display_bytes_for(bytes, password)
|
||||
};
|
||||
let mut r =
|
||||
PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
|
||||
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
|
||||
if let Some(e) = p.error {
|
||||
return Err(e.into());
|
||||
}
|
||||
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();
|
||||
@@ -621,7 +697,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
fn check_one(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("check-one: missing file")?;
|
||||
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
|
||||
let start = Instant::now();
|
||||
let bytes = read(path)?;
|
||||
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
|
||||
@@ -713,12 +789,23 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
|
||||
|
||||
fn check(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("check", args, CHECK_OPTIONS)?;
|
||||
// Every option is checked before the inputs are scanned, so a bad value is one message,
|
||||
// not one problem per file: a zero timeout used to make every file time out, and a bad
|
||||
// dpi used to reach each child, which printed nothing and was reported as a crash.
|
||||
let timeout_value = flag(args, "--timeout").unwrap_or("20");
|
||||
let timeout = Duration::from_secs(
|
||||
timeout_value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|secs| *secs > 0)
|
||||
.ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
|
||||
);
|
||||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||||
parse_dpi(&dpi)?;
|
||||
let files = collect(&positional(args));
|
||||
if files.is_empty() {
|
||||
return Err("check: no PDF files found".into());
|
||||
}
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
|
||||
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let mut results = Vec::new();
|
||||
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The robustness harness visits each directory once, including symlink aliases and cycles.
|
||||
//! Its `--timeout` and `--dpi` options are checked before anything is scanned.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() {
|
||||
assert_eq!(results[0]["status"], "ok");
|
||||
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to
|
||||
/// make every file time out at once, and a dpi that is not a positive finite number used to
|
||||
/// reach each child, which printed nothing and was reported as a crash per file.
|
||||
#[test]
|
||||
fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
|
||||
let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n");
|
||||
for value in ["abc", "0", "-3", "NaN"] {
|
||||
let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "--dpi {value}");
|
||||
assert_eq!(
|
||||
out.stderr,
|
||||
format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(),
|
||||
"--dpi {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,9 @@ mod one_based_arguments {
|
||||
for destination in ["output.pam", "new.pam", "input.pdf"] {
|
||||
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
|
||||
for (command, option, value, diagnostic) in [
|
||||
("render", "--page", "0", "--page must be at least 1"),
|
||||
("edit", "--move", "1:0", "--move target must be at least 1"),
|
||||
("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
|
||||
("render", "--page", "0", "bad --page: expected a positive page number"),
|
||||
("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
|
||||
("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
|
||||
] {
|
||||
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
|
||||
@@ -194,6 +194,33 @@ mod one_based_arguments {
|
||||
}
|
||||
}
|
||||
|
||||
/// A value the option cannot hold names the option and the value, and fails before the input
|
||||
/// file is opened: a missing input still gets the option's own message.
|
||||
#[test]
|
||||
fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() {
|
||||
let dir = tmp("values", "");
|
||||
for (option, value, diagnostic) in [
|
||||
("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""),
|
||||
("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"),
|
||||
("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"),
|
||||
("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""),
|
||||
("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
|
||||
("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""),
|
||||
("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
|
||||
] {
|
||||
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{option} {value}");
|
||||
assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
|
||||
assert!(out.stdout.is_empty(), "{option} {value}");
|
||||
assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written");
|
||||
}
|
||||
// A negative multiple of 90 is still a rotation the engine accepts: the refusal above is
|
||||
// the multiple-of-90 rule, not a sign problem (the missing input is the next error).
|
||||
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
/// A destination past the end must fail, not silently become an append (#1330).
|
||||
#[test]
|
||||
fn a_destination_past_the_end_fails_and_writes_nothing() {
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod audit;
|
||||
mod automation_roots;
|
||||
mod batch;
|
||||
mod check;
|
||||
mod edit;
|
||||
mod extract;
|
||||
mod help;
|
||||
mod info_standards;
|
||||
mod mcp_compact;
|
||||
mod options;
|
||||
mod owner_password;
|
||||
mod render;
|
||||
mod root_arguments;
|
||||
mod run_report;
|
||||
mod run_root;
|
||||
mod split;
|
||||
mod stderr;
|
||||
mod stdout;
|
||||
mod text;
|
||||
mod ui_control;
|
||||
mod version_resource;
|
||||
mod xfa;
|
||||
@@ -0,0 +1,154 @@
|
||||
//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open`
|
||||
//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so
|
||||
//! the CLI must hand it the user password that owner authentication recovers.
|
||||
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Algorithm, NewEncryption};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`.
|
||||
fn fixture(content: &str) -> Vec<u8> {
|
||||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||||
let objs: [&[u8]; 5] = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
|
||||
stream.as_bytes(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
|
||||
|
||||
/// `fixture` protected with user password "user" and owner password "owner", allowing nothing,
|
||||
/// so copying its text needs the owner password.
|
||||
fn protected(algorithm: Algorithm) -> Vec<u8> {
|
||||
protected_with(algorithm, PAGE)
|
||||
}
|
||||
|
||||
fn protected_with(algorithm: Algorithm, content: &str) -> Vec<u8> {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: 0,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
}
|
||||
|
||||
const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
|
||||
|
||||
#[test]
|
||||
fn info_accepts_the_owner_password() {
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("info-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected(alg)).unwrap();
|
||||
for pw in ["owner", "user"] {
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
|
||||
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}");
|
||||
assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}");
|
||||
}
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap();
|
||||
assert!(!out.status.success(), "{alg:?}: a wrong password still fails");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_accepts_the_owner_password() {
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("text-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected(alg)).unwrap();
|
||||
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{alg:?}: {stderr}");
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}");
|
||||
// The user password opens it, but the permissions still forbid copying.
|
||||
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap();
|
||||
assert!(!out.status.success(), "{alg:?}: copying needs the owner password");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn:
|
||||
/// a blank page of the right size fails too.
|
||||
fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut buf).unwrap();
|
||||
assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}");
|
||||
// A rendered page is opaque, so `render` writes it as 8-bit RGB (`pdfcraft_engine::export::encode_png`).
|
||||
assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgb, png::BitDepth::Eight), "{what}");
|
||||
// Pixels with every colour channel below half intensity: ink, not white paper.
|
||||
let inked = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().filter(|px| px.iter().all(|c| *c < 128)).count();
|
||||
assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_accepts_the_owner_password() {
|
||||
const DPI: u32 = 144;
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("render-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected(alg)).unwrap();
|
||||
for pw in ["owner", "user"] {
|
||||
let png = tmp(&format!("render-{alg:?}-{pw}.png"));
|
||||
let _ = std::fs::remove_file(&png);
|
||||
let out = Command::new(BIN)
|
||||
.args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()])
|
||||
.args(["--password", pw, "--out", png.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
|
||||
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_places_findings_with_the_owner_password() {
|
||||
// A black box over the text: a faux redaction, so `audit` reads the page geometry too.
|
||||
let covered = format!("{PAGE} 0 g 10 140 120 40 re f");
|
||||
for alg in ALGORITHMS {
|
||||
let path = tmp(&format!("audit-{alg:?}.pdf"));
|
||||
std::fs::write(&path, protected_with(alg, &covered)).unwrap();
|
||||
let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}");
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}"));
|
||||
assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}");
|
||||
// Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom.
|
||||
assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -77,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() {
|
||||
assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive
|
||||
/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but
|
||||
/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead
|
||||
/// of reaching the renderer as a scale it refuses with its own internal message.
|
||||
#[test]
|
||||
fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() {
|
||||
for (option, value, diagnostic) in [
|
||||
("--page", "abc", "bad --page: expected a positive page number"),
|
||||
("--page", "0", "bad --page: expected a positive page number"),
|
||||
("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""),
|
||||
("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""),
|
||||
("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""),
|
||||
("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""),
|
||||
("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""),
|
||||
] {
|
||||
let out = tmp("refuse", "out.png");
|
||||
// `missing.pdf` is never read: the option's own message is the error.
|
||||
let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap();
|
||||
assert_eq!(r.status.code(), Some(1), "{option} {value}");
|
||||
assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
|
||||
assert!(!out.exists(), "{option} {value}: no output may be written");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't.
|
||||
//! `--every` must be a positive whole number of pages, checked before the file is opened.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
@@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() {
|
||||
assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to
|
||||
/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone.
|
||||
#[test]
|
||||
fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() {
|
||||
let dir = workdir("every");
|
||||
for value in ["0", "abc"] {
|
||||
let out_dir = dir.join("parts");
|
||||
let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "--every {value}");
|
||||
assert_eq!(
|
||||
out.stderr,
|
||||
format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(),
|
||||
"--every {value}"
|
||||
);
|
||||
assert!(!out_dir.exists(), "--every {value}: nothing may be written");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# Records from every crate go to standard error and the log file (src/logging.rs).
|
||||
log = { workspace = true }
|
||||
# The log timestamp goes through chrono (no hand-rolled calendar code).
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited
|
||||
@@ -54,8 +56,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
|
||||
# and `advapi` for the session id that names the single-instance socket (#3338).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
|
||||
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
|
||||
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
|
||||
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
|
||||
|
||||
@@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||
/// is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -337,6 +345,18 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
|
||||
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
|
||||
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent>
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
|
||||
//! --page-gaps on|off --default-page-gaps on|off
|
||||
//! --combine-view grid|list --combine-zoom 60..200`
|
||||
//!
|
||||
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
|
||||
@@ -231,7 +232,10 @@ fn main() -> eframe::Result {
|
||||
// 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,
|
||||
@@ -377,6 +381,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();
|
||||
@@ -553,6 +558,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
|
||||
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
|
||||
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
|
||||
/// its window appeared. D3D12 is the native, best-supported backend there.
|
||||
/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain
|
||||
/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window
|
||||
/// is being resized, Windows stretched the window's swap chain, still at the old size, to the
|
||||
/// new one, so the text shimmered and the left panel's width bounced for as long as the drag
|
||||
/// lasted (issue #2738). A visual's last frame is shown at its own size instead.
|
||||
fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
@@ -562,7 +572,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| {
|
||||
@@ -600,11 +610,21 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL;
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env());
|
||||
}
|
||||
}
|
||||
|
||||
/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless
|
||||
/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`).
|
||||
fn dx12_presentation(asked: Option<eframe::wgpu::Dx12SwapchainKind>) -> eframe::wgpu::Dx12SwapchainKind {
|
||||
asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual)
|
||||
}
|
||||
|
||||
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
|
||||
/// 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
|
||||
}
|
||||
@@ -760,7 +780,7 @@ mod tests {
|
||||
/// graphics libraries at a time. The test threads otherwise run them together, and on
|
||||
/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
|
||||
/// the first device was still being set up when the second test began its own. The UI
|
||||
/// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
|
||||
/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole
|
||||
/// test (declared before the instance, so it's released after the device is dropped).
|
||||
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
@@ -873,7 +893,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
|
||||
fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() {
|
||||
let mut native = eframe::NativeOptions::default();
|
||||
super::configure_gpu(&mut native);
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
|
||||
@@ -891,6 +911,18 @@ mod tests {
|
||||
assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
|
||||
assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
|
||||
}
|
||||
use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual};
|
||||
assert_eq!(super::dx12_presentation(None), DxgiFromVisual);
|
||||
assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides");
|
||||
assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual);
|
||||
let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system;
|
||||
if cfg!(target_os = "windows") {
|
||||
// A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise.
|
||||
let asked = eframe::wgpu::Dx12SwapchainKind::from_env();
|
||||
assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain");
|
||||
} else {
|
||||
assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -909,16 +941,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;
|
||||
|
||||
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
|
||||
/// `%LOCALAPPDATA%` is unset.
|
||||
#[cfg(windows)]
|
||||
fn socket_path() -> Option<std::path::PathBuf> {
|
||||
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
|
||||
let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
|
||||
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
|
||||
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
|
||||
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
|
||||
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
|
||||
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
|
||||
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
|
||||
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
|
||||
/// The Windows session this process runs in, from its access token: the console, each Remote
|
||||
/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
|
||||
#[cfg(windows)]
|
||||
fn session_id() -> Option<u32> {
|
||||
use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
|
||||
match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
|
||||
Ok(TokenInfo::SessionId(id)) => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session. The same user signed in twice (at the console
|
||||
/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
|
||||
/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
|
||||
/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
|
||||
/// window of the session it runs in (#3338), and never one in another session (review on #3340).
|
||||
/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
|
||||
/// digits and `-` kept.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn socket_name(session: Option<&str>) -> String {
|
||||
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
|
||||
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
|
||||
fn socket_name(session: Option<u32>, session_name: Option<&str>) -> String {
|
||||
if let Some(id) = session {
|
||||
return format!("instance-s{id}.sock");
|
||||
}
|
||||
let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
|
||||
if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
|
||||
}
|
||||
|
||||
/// Held while a launch finds out whether it is the first, so launches that start together (several
|
||||
@@ -359,12 +377,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn each_windows_session_gets_its_own_socket_name() {
|
||||
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
|
||||
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
|
||||
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
|
||||
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
|
||||
assert_eq!(socket_name(None), "instance.sock");
|
||||
assert_eq!(socket_name(Some("")), "instance.sock");
|
||||
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
|
||||
// console's session finds the window opened from Explorer there...
|
||||
assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
|
||||
// ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
|
||||
assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
|
||||
assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
|
||||
assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
|
||||
// Only without an id does SESSIONNAME name it.
|
||||
assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
|
||||
assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
|
||||
assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
|
||||
assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
|
||||
assert_eq!(socket_name(None, None), "instance.sock");
|
||||
assert_eq!(socket_name(None, Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -394,11 +420,21 @@ mod tests {
|
||||
assert_eq!(display_socket_name(Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
/// The socket is named by the session the process runs in, which Windows always gives.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn this_process_has_a_session_id() {
|
||||
let id = super::session_id();
|
||||
assert!(id.is_some(), "the access token names the session");
|
||||
let name = socket_name(id, None);
|
||||
assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn socket(test: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir.join(socket_name(None))
|
||||
dir.join(socket_name(None, None))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
// Compile winit's scroll policy against its real event type, without a compositor. The vendored
|
||||
// source (`winit_wayland_scroll`) names it as `crate::event`.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
|
||||
mod winit_macos_emoji_picker;
|
||||
mod winit_macos_ime_commit;
|
||||
mod winit_macos_ime_discard;
|
||||
mod winit_macos_korean_first_key;
|
||||
mod winit_macos_korean_space;
|
||||
mod winit_macos_title_bar_drag;
|
||||
mod winit_wayland_scroll;
|
||||
mod winit_wayland_text_input;
|
||||
mod winit_windows_hidden_maximized;
|
||||
mod winit_windows_ime_cursor;
|
||||
mod winit_x11_dpi;
|
||||
mod winit_x11_ime_focus;
|
||||
+2
-5
@@ -3,11 +3,8 @@
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
|
||||
use wayland_client::protocol::wl_pointer::AxisSource;
|
||||
|
||||
// Compile the production policy against winit's real event type, without a compositor.
|
||||
mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
|
||||
use crate::event;
|
||||
#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
|
||||
mod scroll;
|
||||
|
||||
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
|
||||
+10
-10
@@ -401,12 +401,12 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
</div>
|
||||
<h3>The families in this specimen</h3>
|
||||
<div class="families">
|
||||
<div><span style="font-family:'PC Playfair'">Ag</span><b>Playfair Display</b>Display</div>
|
||||
<div><span style="font-family:'PC Garamond'">Ag</span><b>EB Garamond</b>Body text</div>
|
||||
<div><span style="font-family:'PC Inter';font-weight:500">Ag</span><b>Inter</b>Labels</div>
|
||||
<div><span style="font-family:'PC Mono'">Ag</span><b>JetBrains Mono</b>Code</div>
|
||||
<div><span style="font-family:'PC Vibes'">Ag</span><b>Great Vibes</b>Script</div>
|
||||
<div><span style="font-family:'PC Math'">∑x</span><b>STIX Two Math</b>Mathematics</div>
|
||||
<div><span style="font-family:'PC Playfair', serif">Ag</span><b>Playfair Display</b>Display</div>
|
||||
<div><span style="font-family:'PC Garamond', serif">Ag</span><b>EB Garamond</b>Body text</div>
|
||||
<div><span style="font-family:'PC Inter', sans-serif;font-weight:500">Ag</span><b>Inter</b>Labels</div>
|
||||
<div><span style="font-family:'PC Mono', monospace">Ag</span><b>JetBrains Mono</b>Code</div>
|
||||
<div><span style="font-family:'PC Vibes', cursive">Ag</span><b>Great Vibes</b>Script</div>
|
||||
<div><span style="font-family:'PC Math', math">∑x</span><b>STIX Two Math</b>Mathematics</div>
|
||||
</div>
|
||||
<div class="folio"><span>Foreword</span><b>ii</b></div>
|
||||
</section>
|
||||
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
<tr><td class="tag">onum</td><td class="lbl">Old-style figures</td><td class="off" style="font-feature-settings:'lnum' 1">Since 1885: 3,467,920</td><td class="on" style="font-feature-settings:'onum' 1">Since 1885: 3,467,920</td></tr>
|
||||
<tr><td class="tag">frac</td><td class="lbl">Fractions</td><td class="off" style="font-family:var(--ui)">1/2 cup, 3/4 inch, 7/8</td><td class="on" style="font-family:var(--ui);font-feature-settings:'frac' 1">1/2 cup, 3/4 inch, 7/8</td></tr>
|
||||
<tr><td class="tag">swsh</td><td class="lbl">Swash capitals</td><td class="off" style="font-style:italic">The Quiet River</td><td class="on" style="font-style:italic;font-feature-settings:'swsh' 1">The Quiet River</td></tr>
|
||||
<tr><td class="tag">ss01</td><td class="lbl">Stylistic set 1</td><td class="off" style="font-family:'PC Inter'">a 1 4 6 9 G ?</td><td class="on" style="font-family:'PC Inter';font-feature-settings:'ss01' 1">a 1 4 6 9 G ?</td></tr>
|
||||
<tr><td class="tag">ss01</td><td class="lbl">Stylistic set 1</td><td class="off" style="font-family:'PC Inter', sans-serif">a 1 4 6 9 G ?</td><td class="on" style="font-family:'PC Inter', sans-serif;font-feature-settings:'ss01' 1">a 1 4 6 9 G ?</td></tr>
|
||||
<tr><td class="tag">kern</td><td class="lbl">Kerning</td><td class="off" style="font-kerning:none;font-family:var(--sans)">AVATAR Type Wave</td><td class="on" style="font-kerning:normal;font-family:var(--sans)">AVATAR Type Wave</td></tr>
|
||||
<tr><td class="tag">tnum</td><td class="lbl">Tabular figures</td><td class="off" style="font-family:var(--ui);font-feature-settings:'pnum' 1">1111 / 8888</td><td class="on" style="font-family:var(--ui);font-feature-settings:'tnum' 1">1111 / 8888</td></tr>
|
||||
</table>
|
||||
@@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
<p class="dek">A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1in;gap:16pt">
|
||||
<div class="scripts">
|
||||
<div class="sc" lang="ar" dir="rtl"><div class="nm" dir="ltr">Arabic <i>RTL · joining</i></div><div class="tx" style="font-family:'PC Amiri'">لكل حرف بيت</div><div class="en" dir="ltr">Amiri</div></div>
|
||||
<div class="sc" lang="ar" dir="rtl"><div class="nm" dir="ltr">Arabic <i>RTL · joining</i></div><div class="tx" style="font-family:'PC Amiri', serif">لكل حرف بيت</div><div class="en" dir="ltr">Amiri</div></div>
|
||||
<div class="sc" lang="he" dir="rtl"><div class="nm" dir="ltr">Hebrew <i>RTL</i></div><div class="tx" style="font-family:'PC Hebrew',sans-serif">לכל אות יש בית טוב</div><div class="en" dir="ltr">Noto Sans Hebrew</div></div>
|
||||
<div class="sc" lang="hi"><div class="nm">Devanagari <i>conjuncts</i></div><div class="tx" style="font-family:'PC Devanagari'">हर अक्षर का घर</div><div class="en">Noto Sans Devanagari</div></div>
|
||||
<div class="sc" lang="hi"><div class="nm">Devanagari <i>conjuncts</i></div><div class="tx" style="font-family:'PC Devanagari', sans-serif">हर अक्षर का घर</div><div class="en">Noto Sans Devanagari</div></div>
|
||||
<div class="sc" lang="th"><div class="nm">Thai <i>no spaces</i></div><div class="tx" style="font-family:'PC Thai',sans-serif">ทุกอักษรมีบ้าน</div><div class="en">Noto Sans Thai</div></div>
|
||||
<div class="sc" lang="el"><div class="nm">Greek <i>polytonic</i></div><div class="tx" style="font-family:'PC Garamond',serif">Κάθε γράμμα ἔχει σπίτι</div><div class="en">EB Garamond</div></div>
|
||||
<div class="sc" lang="ru"><div class="nm">Cyrillic</div><div class="tx" style="font-family:'PC Garamond',serif">Каждой букве — дом</div><div class="en">EB Garamond</div></div>
|
||||
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
|
||||
</div>
|
||||
|
||||
<h3>Mixed direction in one line</h3>
|
||||
<p style="font-size:12pt;margin:0">The word <span lang="ar" style="font-family:'PC Amiri'">كتاب</span> means “book”, and <span lang="he" style="font-family:'PC Hebrew'">ספר</span> does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like <span lang="ar" style="font-family:'PC Amiri'">٣٤٥</span> and 345.</p>
|
||||
<p style="font-size:12pt;margin:0">The word <span lang="ar" style="font-family:'PC Amiri', serif">كتاب</span> means “book”, and <span lang="he" style="font-family:'PC Hebrew', serif">ספר</span> does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like <span lang="ar" style="font-family:'PC Amiri', serif">٣٤٥</span> and 345.</p>
|
||||
|
||||
<h3>Colour emoji</h3>
|
||||
<div class="emoji">📄🖋️🎨📐🔍🌍🧮✅🦀📎</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-move-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m18 8 4 4-4 4" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m6 8-4 4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-pilcrow"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 4v16" />
|
||||
<path d="M17 4v16" />
|
||||
<path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-subscript"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m4 5 8 8" />
|
||||
<path d="m12 5-8 8" />
|
||||
<path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-superscript"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m4 19 8-8" />
|
||||
<path d="m12 19-8-8" />
|
||||
<path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 0 0-2.617-.436c-.42.239-.738.614-.899 1.06" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -20,6 +20,11 @@
|
||||
[people.YouseifUS]
|
||||
sync_github_name = true
|
||||
|
||||
[people.brian-moon]
|
||||
real_name = "Il Min Brian Moon"
|
||||
display_name = "Il Min Brian Moon"
|
||||
sync_github_name = false
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -30,6 +35,9 @@ real_name = "Eduardo Soares"
|
||||
display_name = "Eduardo Soares"
|
||||
sync_github_name = false
|
||||
|
||||
[people.guillehr2]
|
||||
real_name = "Guillem Hermida"
|
||||
|
||||
[people.jcl-bit]
|
||||
real_name = "James Locke"
|
||||
display_name = "James Locke"
|
||||
@@ -145,3 +153,23 @@ sync_github_name = false
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.EricKaden]
|
||||
real_name = "Eric Kaden"
|
||||
display_name = "Eric Kaden"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MatrixMagician]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Lcoding15]
|
||||
real_name = "Livio Oswald"
|
||||
|
||||
[people.Bode512]
|
||||
sync_github_name = true
|
||||
|
||||
[people.amoisttowe1]
|
||||
real_name = "Nick Nyssen"
|
||||
|
||||
@@ -16,6 +16,10 @@ let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); /
|
||||
- Each result is Passed, Failed (with findings and pages), Needs manual check, or Skipped.
|
||||
- **Content checks** walk the page content streams (and form XObjects) tracking marked
|
||||
content: painting outside MCID-marked or `/Artifact` content is untagged.
|
||||
- **Character encoding** accepts a predefined non-Identity CJK CMap or an Adobe CJK
|
||||
character collection (`GB1`, `CNS1`, `Japan1`, `Korea1`, `KR`), including Identity-H/V.
|
||||
Unknown CMaps and foreign registries do not establish that route. This is a font-level
|
||||
declaration check, not validation of every ToUnicode entry or used glyph.
|
||||
- **Structure checks** read the structure tree through the role map; table regularity counts
|
||||
`ColSpan`/`RowSpan` attributes.
|
||||
- The report is a self-contained HTML page. Fixes that edit the document (language, title, tab
|
||||
|
||||
+27
-24
@@ -165,7 +165,7 @@ pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
.flat_map(|x| match &*doc.resolve(x) {
|
||||
Object::Stream(s) => {
|
||||
let mut v = s.decoded().unwrap_or_default();
|
||||
v.push(b'\n');
|
||||
v.push(b' ');
|
||||
v
|
||||
}
|
||||
_ => Vec::new(),
|
||||
@@ -321,25 +321,33 @@ fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet
|
||||
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
|
||||
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
match &*doc.resolve(entry) {
|
||||
Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))),
|
||||
_ => streams.push(None),
|
||||
}
|
||||
}
|
||||
let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let edits: Vec<_> =
|
||||
pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
|
||||
if edits.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut spliced = pieces.splice(edits).into_iter();
|
||||
let mut next = Vec::with_capacity(entries.len());
|
||||
let mut changed = false;
|
||||
for entry in entries {
|
||||
let Some(stream) = (match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
for (entry, stream) in entries.into_iter().zip(streams) {
|
||||
let Some((stream, old)) = stream else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
};
|
||||
let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops;
|
||||
let mut ours = false;
|
||||
for op in &mut ops {
|
||||
if marked(doc, &props, op, ids) {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
ours = true;
|
||||
}
|
||||
}
|
||||
if !ours {
|
||||
let Some(new) = spliced.next() else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
};
|
||||
if new == old {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
@@ -348,15 +356,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"Filter");
|
||||
dict.remove(b"DecodeParms");
|
||||
let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops)));
|
||||
let stored = match entry {
|
||||
Object::Ref(r) => {
|
||||
doc.set(r, stream);
|
||||
Object::Ref(r)
|
||||
}
|
||||
_ => Object::Ref(doc.add(stream)),
|
||||
};
|
||||
next.push(stored);
|
||||
let stream = Object::Stream(Stream::flate(dict, &new));
|
||||
// Splicing depends on this occurrence's neighbours. The original stream may also
|
||||
// appear elsewhere in this array or on another page, where it must stay unchanged.
|
||||
next.push(Object::Ref(doc.add(stream)));
|
||||
}
|
||||
if changed {
|
||||
let contents = match next.as_slice() {
|
||||
|
||||
@@ -70,7 +70,7 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
|
||||
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
|
||||
};
|
||||
all.extend_from_slice(&decoded);
|
||||
all.push(b'\n');
|
||||
all.push(b' ');
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
+82
-11
@@ -47,7 +47,7 @@ fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
.flat_map(|x| match &*doc.resolve(x) {
|
||||
Object::Stream(s) => {
|
||||
let mut v = s.decoded().unwrap_or_default();
|
||||
v.push(b'\n');
|
||||
v.push(b' ');
|
||||
v
|
||||
}
|
||||
_ => Vec::new(),
|
||||
@@ -143,6 +143,28 @@ fn catalog(doc: &Document) -> Dict {
|
||||
doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The most references one value may be written through. A chain is unusual but legal, and the
|
||||
/// render inspector follows them (lopdf's `dereference` has a limit of its own), so the two
|
||||
/// readers of a flag must agree about one.
|
||||
const MAX_REFERENCE_CHAIN: usize = 32;
|
||||
|
||||
/// The value a chain of indirect references ends at. [`Document::resolve`] follows one reference,
|
||||
/// and an indirect object's value may itself be a reference (ISO 32000-2 §7.3.10): `/Marked
|
||||
/// 8 0 R` with object 8 holding `9 0 R` holding `true` is a tagged document, which this rule read
|
||||
/// as untagged while `doc_info` read it as tagged. Bounded and cycle-safe, since a damaged file
|
||||
/// can point a reference at itself; a chain longer than that, or one that loops, is no value.
|
||||
fn resolved(doc: &Document, o: &Object) -> std::sync::Arc<Object> {
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut value = doc.resolve(o);
|
||||
while let Object::Ref(r) = &*value {
|
||||
if seen.len() >= MAX_REFERENCE_CHAIN || !seen.insert(*r) {
|
||||
return std::sync::Arc::new(Object::Null);
|
||||
}
|
||||
value = doc.get(*r);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
|
||||
}
|
||||
@@ -167,12 +189,16 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
|
||||
}
|
||||
}
|
||||
Rule::TaggedPdf => {
|
||||
let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned()));
|
||||
// `/Marked` may be an indirect reference like any other value -- or a chain of
|
||||
// them -- so it is followed to the value before the test below, which looks for the
|
||||
// boolean itself (#3671).
|
||||
let marked =
|
||||
cat.get(b"MarkInfo").map(|m| resolved(doc, m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").map(|v| resolved(doc, v))));
|
||||
let mut out = Vec::new();
|
||||
if !cat.contains(b"StructTreeRoot") {
|
||||
out.push(Finding::plain(None, "The document has no tags (structure tree)"));
|
||||
}
|
||||
if !matches!(marked, Some(Object::Bool(true))) {
|
||||
if !matches!(marked.as_deref(), Some(Object::Bool(true))) {
|
||||
out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)"));
|
||||
}
|
||||
out
|
||||
@@ -360,8 +386,47 @@ fn full_name(doc: &Document, field: ObjRef) -> String {
|
||||
parts.join(".")
|
||||
}
|
||||
|
||||
/// Whether text in this font can be mapped to Unicode (ToUnicode, a standard encoding, or a
|
||||
/// predefined CJK character collection).
|
||||
/// Predefined CJK CMaps (ISO 32000-2 §9.7.5.2, Table 116), excluding Identity-H/V.
|
||||
/// Test complete names: neither a custom CMap nor a nonexistent vertical variant is predefined.
|
||||
fn predefined_cjk_cmap(name: &[u8]) -> bool {
|
||||
if matches!(name, b"H" | b"V" | b"83pv-RKSJ-H" | b"90pv-RKSJ-H" | b"KSCpc-EUC-H" | b"UniAKR-UTF8-H" | b"UniAKR-UTF16-H" | b"UniAKR-UTF32-H") {
|
||||
return true;
|
||||
}
|
||||
let Some(base) = name.strip_suffix(b"-H").or_else(|| name.strip_suffix(b"-V")) else { return false };
|
||||
matches!(
|
||||
base,
|
||||
b"GB-EUC"
|
||||
| b"GBpc-EUC"
|
||||
| b"GBK-EUC"
|
||||
| b"GBKp-EUC"
|
||||
| b"GBK2K"
|
||||
| b"UniGB-UCS2"
|
||||
| b"UniGB-UTF16"
|
||||
| b"B5pc"
|
||||
| b"HKscs-B5"
|
||||
| b"ETen-B5"
|
||||
| b"ETenms-B5"
|
||||
| b"CNS-EUC"
|
||||
| b"UniCNS-UCS2"
|
||||
| b"UniCNS-UTF16"
|
||||
| b"90ms-RKSJ"
|
||||
| b"90msp-RKSJ"
|
||||
| b"Add-RKSJ"
|
||||
| b"EUC"
|
||||
| b"Ext-RKSJ"
|
||||
| b"UniJIS-UCS2"
|
||||
| b"UniJIS-UCS2-HW"
|
||||
| b"UniJIS-UTF16"
|
||||
| b"KSC-EUC"
|
||||
| b"KSCms-UHC"
|
||||
| b"KSCms-UHC-HW"
|
||||
| b"UniKS-UCS2"
|
||||
| b"UniKS-UTF16"
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether text in this font declares a Unicode mapping route (ToUnicode, a standard encoding,
|
||||
/// a predefined CJK CMap, or an Adobe CJK character collection). This does not validate every glyph.
|
||||
fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
|
||||
if f.contains(b"ToUnicode") {
|
||||
return true;
|
||||
@@ -369,16 +434,22 @@ fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
|
||||
let enc = f.get(b"Encoding").map(|e| doc.resolve(e));
|
||||
match f.name(b"Subtype") {
|
||||
Some(b"Type0") => {
|
||||
let identity = matches!(enc.as_deref(), Some(Object::Name(n)) if n.starts_with(b"Identity"));
|
||||
let ordering = f
|
||||
// ISO 32000-2 §9.10.2: either a predefined non-Identity CMap or the descendant's
|
||||
// Adobe CJK collection supplies the route. Identity-H/V do not rule out the latter.
|
||||
if matches!(enc.as_deref(), Some(Object::Name(n)) if predefined_cjk_cmap(n)) {
|
||||
return true;
|
||||
}
|
||||
let system = f
|
||||
.get(b"DescendantFonts")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().and_then(|a| a.first().cloned()))
|
||||
.and_then(|d| doc.resolve(&d).as_dict().cloned())
|
||||
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()))
|
||||
.and_then(|c| c.get(b"Ordering").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone())));
|
||||
let known = matches!(ordering.as_deref(), Some(b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"));
|
||||
!identity && known
|
||||
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()));
|
||||
let Some(system) = system else { return false };
|
||||
let registry = system.get(b"Registry").map(|r| doc.resolve(r));
|
||||
let ordering = system.get(b"Ordering").map(|o| doc.resolve(o));
|
||||
matches!(registry.as_deref(), Some(Object::String(s)) if s.bytes == b"Adobe")
|
||||
&& matches!(ordering.as_deref(), Some(Object::String(s)) if matches!(s.bytes.as_slice(), b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"))
|
||||
}
|
||||
Some(b"Type3") => enc.is_some(),
|
||||
_ => match enc.as_deref() {
|
||||
|
||||
@@ -263,7 +263,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
tm = tlm;
|
||||
}
|
||||
let pieces: Vec<&Object> = match op.op.as_slice() {
|
||||
b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
|
||||
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still takes the
|
||||
// one immediately before it, as the renderer and every other reader here do (#3569).
|
||||
b"TJ" => op.operands.last().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
|
||||
_ => op.operands.last().into_iter().collect(),
|
||||
};
|
||||
for piece in pieces {
|
||||
|
||||
@@ -192,6 +192,44 @@ fn an_untagged_document_fails_the_document_rules() {
|
||||
assert_eq!((only.count(Status::Failed), only.count(Status::Skipped)), (1, 31));
|
||||
}
|
||||
|
||||
/// `/MarkInfo /Marked` may be written as an indirect reference, like any value. The rule read the
|
||||
/// reference itself and so reported a tagged document as untagged (#3671).
|
||||
#[test]
|
||||
fn marked_as_an_indirect_reference_still_counts_as_tagged() {
|
||||
let mut bodies = good();
|
||||
// The catalog is object 1; point /Marked at a new object holding `true`.
|
||||
let last = bodies.len() + 1;
|
||||
bodies[0] = bodies[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {last} 0 R >>"));
|
||||
bodies.push("true".into());
|
||||
let doc = pdf(&bodies, "");
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedPdf].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report));
|
||||
|
||||
// A reference to a reference is followed too: the render inspector follows chains, so the
|
||||
// two readers of the flag would otherwise disagree about one file.
|
||||
let mut chain = good();
|
||||
let first = chain.len() + 1;
|
||||
chain[0] = chain[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {first} 0 R >>"));
|
||||
chain.push(format!("{} 0 R", first + 1));
|
||||
chain.push("true".into());
|
||||
let report = check(&pdf(&chain, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report));
|
||||
|
||||
// A reference that points at itself is no value, not a hang.
|
||||
let mut loops = good();
|
||||
let at = loops.len() + 1;
|
||||
loops[0] = loops[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {at} 0 R >>"));
|
||||
loops.push(format!("{at} 0 R"));
|
||||
let report = check(&pdf(&loops, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Failed);
|
||||
|
||||
// The direct form is unchanged, and a document with no flag at all still fails.
|
||||
assert_eq!(status(&check(&pdf(&good(), ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Passed);
|
||||
let mut none = good();
|
||||
none[0] = none[0].replace("/MarkInfo << /Marked true >>", "");
|
||||
assert_eq!(status(&check(&pdf(&none, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Failed);
|
||||
}
|
||||
|
||||
/// A tagged document that passes every automatic rule.
|
||||
fn good() -> Vec<String> {
|
||||
vec![
|
||||
@@ -536,6 +574,126 @@ fn scans_identity_fonts_and_long_documents() {
|
||||
assert_eq!(some.result(Rule::CharacterEncoding).unwrap().findings[0].page, Some(3));
|
||||
}
|
||||
|
||||
fn cid_encoding_pdf(encoding: &str, system: &str, to_unicode: bool) -> Document {
|
||||
let unicode = if to_unicode { "/ToUnicode 7 0 R" } else { "" };
|
||||
pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("/P << /MCID 0 >> BDC BT /F1 12 Tf <002200230024> Tj ET EMC"),
|
||||
format!("<< /Type /Font /Subtype /Type0 /BaseFont /SyntheticCID /Encoding {encoding} /DescendantFonts [6 0 R] {unicode} >>"),
|
||||
format!("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /SyntheticCID /CIDSystemInfo {system} >>"),
|
||||
stream(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /SyntheticUnicode def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 3 beginbfchar <0022> <0041> <0023> <0042> <0024> <0043> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
|
||||
),
|
||||
],
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_accepts_identity_cjk_collections() {
|
||||
for encoding in ["/Identity-H", "/Identity-V"] {
|
||||
for ordering in ["Japan1", "GB1", "CNS1", "Korea1", "KR"] {
|
||||
let system = format!("<< /Registry (Adobe) /Ordering ({ordering}) /Supplement 0 >>");
|
||||
let doc = cid_encoding_pdf(encoding, &system, false);
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
let result = report.result(Rule::CharacterEncoding).unwrap();
|
||||
assert_eq!(result.status, Status::Passed, "{encoding}, Adobe-{ordering}: {:?}", result.findings);
|
||||
assert!(result.findings.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_accepts_predefined_cmaps_without_a_known_collection() {
|
||||
// The predefined CMap and the descendant's character collection are alternative routes.
|
||||
let system = "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>";
|
||||
for name in [
|
||||
"GB-EUC-H",
|
||||
"GB-EUC-V",
|
||||
"GBK2K-H",
|
||||
"GBK2K-V",
|
||||
"UniGB-UTF16-H",
|
||||
"UniGB-UTF16-V",
|
||||
"B5pc-H",
|
||||
"B5pc-V",
|
||||
"UniCNS-UCS2-H",
|
||||
"UniCNS-UCS2-V",
|
||||
"83pv-RKSJ-H",
|
||||
"90pv-RKSJ-H",
|
||||
"90ms-RKSJ-H",
|
||||
"90ms-RKSJ-V",
|
||||
"H",
|
||||
"V",
|
||||
"UniJIS-UCS2-HW-H",
|
||||
"UniJIS-UCS2-HW-V",
|
||||
"KSCpc-EUC-H",
|
||||
"KSC-EUC-V",
|
||||
"KSCms-UHC-HW-H",
|
||||
"KSCms-UHC-HW-V",
|
||||
"UniKS-UTF16-H",
|
||||
"UniKS-UTF16-V",
|
||||
"UniAKR-UTF8-H",
|
||||
"UniAKR-UTF16-H",
|
||||
"UniAKR-UTF32-H",
|
||||
] {
|
||||
let doc = cid_encoding_pdf(&format!("/{name}"), system, false);
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_rejects_unknown_cmaps_and_foreign_collections() {
|
||||
for encoding in
|
||||
["/Identity-H", "/Identity-V", "/Custom-H", "/UniCustom-UTF16-H", "/UniJIS-UCS2-HW-extra-H", "/83pv-RKSJ-V", "/KSCpc-EUC-V", "null"]
|
||||
{
|
||||
for system in [
|
||||
"<< /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>",
|
||||
"<< /Registry (Adobe) /Ordering (Custom) /Supplement 0 >>",
|
||||
"<< /Registry (Private) /Ordering (Japan1) /Supplement 0 >>",
|
||||
"<< /Registry (adobe) /Ordering (Japan1) /Supplement 0 >>",
|
||||
"<< /Ordering (Japan1) /Supplement 0 >>",
|
||||
"<< /Registry /Adobe /Ordering /Japan1 /Supplement 0 >>",
|
||||
"null",
|
||||
] {
|
||||
let doc = cid_encoding_pdf(encoding, system, false);
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
let result = report.result(Rule::CharacterEncoding).unwrap();
|
||||
assert_eq!(result.status, Status::Failed, "{encoding}, {system}");
|
||||
assert_eq!(result.findings.len(), 1);
|
||||
assert_eq!(result.findings[0].page, Some(0));
|
||||
assert!(result.findings[0].message.contains("SyntheticCID"));
|
||||
}
|
||||
}
|
||||
let doc = cid_encoding_pdf("/Identity-H", "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>", true);
|
||||
assert_eq!(status(&checked(&doc, &[Rule::CharacterEncoding]), Rule::CharacterEncoding), Status::Passed, "ToUnicode is still sufficient");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_encoding_resolves_indirect_cid_metadata() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, PdfString};
|
||||
let mut doc = cid_encoding_pdf("/Identity-H", "null", false);
|
||||
let registry = doc.add(Object::String(PdfString::literal(b"Adobe".to_vec())));
|
||||
let ordering = doc.add(Object::String(PdfString::literal(b"Japan1".to_vec())));
|
||||
let mut system = Dict::new();
|
||||
system.set(b"Registry".to_vec(), Object::Ref(registry));
|
||||
system.set(b"Ordering".to_vec(), Object::Ref(ordering));
|
||||
let system = doc.add(Object::Dict(system));
|
||||
doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"CIDSystemInfo".to_vec(), Object::Ref(system))).unwrap();
|
||||
let descendants = doc.add(Object::Array(vec![Object::Ref(ObjRef::new(6, 0))]));
|
||||
let encoding = doc.add(Object::Name(b"Identity-V".to_vec()));
|
||||
doc.update_dict(ObjRef::new(5, 0), |d| {
|
||||
d.set(b"DescendantFonts".to_vec(), Object::Ref(descendants));
|
||||
d.set(b"Encoding".to_vec(), Object::Ref(encoding));
|
||||
})
|
||||
.unwrap();
|
||||
let report = checked(&doc, &[Rule::CharacterEncoding]);
|
||||
assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_have_ids_and_the_report_lists_them() {
|
||||
assert_eq!(Rule::ALL.len(), 32);
|
||||
@@ -926,6 +1084,16 @@ fn an_untagged_document_has_no_headings() {
|
||||
assert!(headings(&pdf(&objs, "")).is_empty());
|
||||
}
|
||||
|
||||
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
|
||||
/// draws only the one immediately before it, as the renderer and every other reader agree (#3569)
|
||||
/// -- not the first, which is never drawn at all.
|
||||
#[test]
|
||||
fn a_heading_with_a_tj_surplus_array_reads_the_one_the_page_draws() {
|
||||
let doc = heading_page(&["BT /F1 20 Tf 72 700 Td [(FIRSTARR)] [(LASTARR)] TJ ET".into()]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["LASTARR"]);
|
||||
}
|
||||
|
||||
/// The text of every `"…"` literal with a space in it, outside comments and byte strings.
|
||||
fn sentences(source: &str) -> Vec<String> {
|
||||
let b = source.as_bytes();
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
use pdfcraft_a11y::{Options, Rule, Status, autotag_text, check, figures, mark_decorative, mark_tag_artifact, tag_content, tags};
|
||||
use pdfcraft_content::{Op, Pieces};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
|
||||
|
||||
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
|
||||
const HIDDEN_AND_AFTER: &str = "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\nBT /F1 12 Tf 72 500 Td (After) Tj ET";
|
||||
|
||||
fn stream(data: &str) -> String {
|
||||
format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())
|
||||
}
|
||||
|
||||
fn build_pdf(objects: &[(u32, String)]) -> Document {
|
||||
let mut raw = Vec::from(&b"%PDF-1.7\n"[..]);
|
||||
let max_num = objects.iter().map(|(n, _)| *n).max().unwrap_or(0);
|
||||
let mut offsets = std::collections::HashMap::new();
|
||||
for (num, body) in objects {
|
||||
offsets.insert(*num, raw.len());
|
||||
raw.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
|
||||
}
|
||||
let xref = raw.len();
|
||||
let size = max_num + 1;
|
||||
raw.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
|
||||
for num in 1..size {
|
||||
if let Some(off) = offsets.get(&num) {
|
||||
raw.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
} else {
|
||||
raw.extend_from_slice(b"0000000000 65535 f \n");
|
||||
}
|
||||
}
|
||||
raw.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
Document::open(std::sync::Arc::new(raw)).expect("valid synthetic PDF")
|
||||
}
|
||||
|
||||
fn untagged_two_streams(first: &str, second: &str) -> Document {
|
||||
build_pdf(&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into()),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [5 0 R 6 0 R] /Resources << /Font << /F1 4 0 R >> >> >>".into()),
|
||||
(4, FONT.into()),
|
||||
(5, stream(first)),
|
||||
(6, stream(second)),
|
||||
])
|
||||
}
|
||||
|
||||
fn tagged_figure_two_streams(after_figure: &str, second: &str) -> Document {
|
||||
let figure = "/Figure << /MCID 0 >> BDC q 50 0 0 50 72 600 cm /Im1 Do Q EMC\n";
|
||||
let first = format!("{figure}{after_figure}");
|
||||
build_pdf(&[
|
||||
(
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>"
|
||||
.into(),
|
||||
),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
|
||||
(
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 /Contents [10 0 R 11 0 R] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> >>".into(),
|
||||
),
|
||||
(4, FONT.into()),
|
||||
(
|
||||
5,
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\x00\x7f\x40\x20\nendstream".into(),
|
||||
),
|
||||
(6, "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 9 0 R >>".into()),
|
||||
(7, "<< /Type /StructElem /S /Document /P 6 0 R /K [8 0 R] >>".into()),
|
||||
(
|
||||
8,
|
||||
"<< /Type /StructElem /S /Figure /P 7 0 R /Pg 3 0 R /K 0 /Alt (A grey square) >>"
|
||||
.into(),
|
||||
),
|
||||
(9, "<< /Nums [0 [8 0 R]] >>".into()),
|
||||
(10, stream(&first)),
|
||||
(11, stream(second)),
|
||||
])
|
||||
}
|
||||
|
||||
fn joined_ops(doc: &Document) -> (Vec<Op>, usize) {
|
||||
let page = &pdfcraft_model::pages(doc)[0];
|
||||
let contents = page.dict.get(b"Contents").expect("page contents");
|
||||
let pieces: Vec<Vec<u8>> = match &*doc.resolve(contents) {
|
||||
Object::Stream(s) => vec![s.decoded().expect("decoded stream")],
|
||||
Object::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(|item| match &*doc.resolve(item) {
|
||||
Object::Stream(s) => Some(s.decoded().expect("decoded stream")),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let joined = Pieces::join(&pieces);
|
||||
let parsed = joined.parse();
|
||||
(parsed.ops, parsed.skipped)
|
||||
}
|
||||
|
||||
fn shown_strings(ops: &[Op]) -> Vec<String> {
|
||||
ops.iter().filter(|op| op.is("Tj")).filter_map(|op| op.operands.first()?.as_string().map(|s| s.to_text())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_leaves_the_other_stream_untouched() {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let before = doc.get(ObjRef::new(11, 0)).clone();
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
assert!(figures(&doc).is_empty());
|
||||
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
|
||||
let page = doc.get(ObjRef::new(3, 0));
|
||||
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
|
||||
}
|
||||
|
||||
/// A and B supply different operands to R's BDC. Reusing R must not let the first
|
||||
/// occurrence's splice remove the unselected second occurrence's BDC.
|
||||
fn split_marked_content(shared: bool) -> Document {
|
||||
let mut doc = tagged_figure_two_streams("", "");
|
||||
doc.set(ObjRef::new(10, 0), Object::Stream(Stream::flate(Dict::new(), b"/Figure << /MCID 0 >>")));
|
||||
let body = Object::Stream(Stream::flate(Dict::new(), b"BDC 0 0 10 10 re f EMC"));
|
||||
doc.set(ObjRef::new(11, 0), body.clone());
|
||||
let b = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Span << /MCID 1 >>")));
|
||||
let second = if shared { ObjRef::new(11, 0) } else { doc.add(body) };
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| {
|
||||
p.set(
|
||||
b"Contents".to_vec(),
|
||||
Object::Array(vec![Object::Ref(ObjRef::new(10, 0)), Object::Ref(ObjRef::new(11, 0)), Object::Ref(b), Object::Ref(second)]),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
Document::open(std::sync::Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
|
||||
}
|
||||
|
||||
fn assert_selected_occurrence_only(shared: bool) {
|
||||
let mut doc = split_marked_content(shared);
|
||||
let (before, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(before.iter().filter(|op| op.is("BDC")).count(), 2);
|
||||
let figure = figures(&doc)[0].obj;
|
||||
mark_decorative(&mut doc, figure).unwrap();
|
||||
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||||
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let (ops, skipped) = joined_ops(&reopened);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(ops.iter().filter(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")).count(), 1);
|
||||
let unselected: Vec<_> = ops.iter().filter(|op| op.is("BDC")).collect();
|
||||
assert_eq!(unselected.len(), 1, "unselected Span must retain its BDC after save/reopen");
|
||||
assert_eq!(unselected[0].name(0), Some(b"Span".as_slice()));
|
||||
assert_eq!(unselected[0].operands[1].as_dict().unwrap().get(b"MCID").and_then(Object::as_int), Some(1));
|
||||
assert_eq!(ops.iter().filter(|op| op.is("re")).count(), 2);
|
||||
assert_eq!(ops.iter().filter(|op| op.is("EMC")).count(), 2);
|
||||
assert!(figures(&reopened).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_isolates_shared_stream_occurrences_after_save() {
|
||||
assert_selected_occurrence_only(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_preserves_unique_stream_occurrences_after_save() {
|
||||
assert_selected_occurrence_only(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_leaves_another_page_using_the_stream_untouched() {
|
||||
let mut doc = tagged_figure_two_streams("", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
|
||||
let other_page = doc.add(Object::Dict(page));
|
||||
doc.update_dict(ObjRef::new(2, 0), |p| {
|
||||
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(other_page)]));
|
||||
p.set(b"Count".to_vec(), Object::Int(2));
|
||||
})
|
||||
.unwrap();
|
||||
let before = doc.get(ObjRef::new(10, 0)).clone();
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).unwrap();
|
||||
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||||
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let pages = pdfcraft_model::pages(&reopened);
|
||||
assert_eq!(pages.len(), 2);
|
||||
let contents = pages[1].dict.get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(*reopened.resolve(&contents[0]), *before, "the other page's shared figure stream must stay intact");
|
||||
let (ops, skipped) = joined_ops(&reopened);
|
||||
assert_eq!(skipped, 0);
|
||||
assert!(ops.iter().any(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_keeps_text_commented_out_across_stream_boundary_hidden() {
|
||||
let mut doc = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream", HIDDEN_AND_AFTER);
|
||||
let result = autotag_text(&mut doc).expect("autotag succeeds");
|
||||
assert_eq!(result.pages, 1);
|
||||
assert_eq!(result.paragraphs, 2, "commented-out text at start of second stream must not be tagged");
|
||||
|
||||
let (ops, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(shown_strings(&ops), ["Visible", "After"]);
|
||||
|
||||
let p_tags: Vec<_> = tags(&doc).into_iter().filter(|t| t.kind == "P").collect();
|
||||
assert_eq!(p_tags.len(), 2);
|
||||
let texts: Vec<_> = p_tags.iter().flat_map(|t| tag_content(&doc, t.object).expect("tag content")).map(|item| item.text).collect();
|
||||
assert_eq!(texts, ["Visible", "After"]);
|
||||
|
||||
// Control: when the comment ends with a newline inside the first stream, Hidden is visible.
|
||||
let mut control = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream\n", HIDDEN_AND_AFTER);
|
||||
let control_res = autotag_text(&mut control).expect("control autotag succeeds");
|
||||
assert_eq!(control_res.paragraphs, 3);
|
||||
let (control_ops, _) = joined_ops(&control);
|
||||
assert_eq!(shown_strings(&control_ops), ["Visible", "Hidden", "After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_preserves_shape_split_across_content_streams() {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50", "re f\nBT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
assert!(figures(&doc).is_empty());
|
||||
|
||||
let (ops, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0, "split rectangle operands must not be dropped as skipped tokens");
|
||||
let re_op = ops.iter().find(|op| op.is("re")).expect("rectangle operator must survive");
|
||||
assert_eq!(re_op.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
|
||||
assert_eq!(shown_strings(&ops), ["After"]);
|
||||
|
||||
// Control: rectangle whole in the first stream.
|
||||
let mut control = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
mark_decorative(&mut control, ObjRef::new(8, 0)).expect("control mark decorative succeeds");
|
||||
let (control_ops, control_skipped) = joined_ops(&control);
|
||||
assert_eq!(control_skipped, 0);
|
||||
let control_re = control_ops.iter().find(|op| op.is("re")).expect("control rectangle");
|
||||
assert_eq!(control_re.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
|
||||
assert_eq!(shown_strings(&control_ops), ["After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_and_mark_artifact_keep_cross_stream_comment_hidden() {
|
||||
let mut doc = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
let (ops, skipped) = joined_ops(&doc);
|
||||
assert_eq!(skipped, 0);
|
||||
assert_eq!(shown_strings(&ops), ["After"], "commented-out Hidden text must not become visible after mark_decorative");
|
||||
|
||||
let mut doc_artifact = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
|
||||
mark_tag_artifact(&mut doc_artifact, ObjRef::new(8, 0)).expect("mark_tag_artifact succeeds");
|
||||
let (artifact_ops, artifact_skipped) = joined_ops(&doc_artifact);
|
||||
assert_eq!(artifact_skipped, 0);
|
||||
assert_eq!(shown_strings(&artifact_ops), ["After"], "commented-out Hidden text must not become visible after mark_tag_artifact");
|
||||
|
||||
// Checker also honours the cross-stream comment when scanning for untagged content.
|
||||
let mut comment_only = tagged_figure_two_streams("% a note at the end of the first stream", "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\n");
|
||||
let report = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(report.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
|
||||
mark_decorative(&mut comment_only, ObjRef::new(8, 0)).expect("mark decorative");
|
||||
let report_after = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(report_after.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
|
||||
color(d, b"IC").flatten().or(fallback)
|
||||
}
|
||||
|
||||
fn border_width(d: &Dict) -> f64 {
|
||||
pub(crate) fn border_width(d: &Dict) -> f64 {
|
||||
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
|
||||
return w.max(0.0);
|
||||
}
|
||||
@@ -559,7 +559,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
c.push_str("h\n");
|
||||
}
|
||||
}
|
||||
c.push_str("f*\n");
|
||||
c.push_str("f\n");
|
||||
return Some(form(rect, c.as_bytes(), res));
|
||||
}
|
||||
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
|
||||
@@ -593,7 +593,28 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// Only PdfCraft's own Fill & Sign marks are drawn here.
|
||||
let name = d.name(b"Name")?;
|
||||
let col = stroke.unwrap_or([0.0; 3]);
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
// A mark or stamp placed on a turned page is drawn upright in its displayed box, then
|
||||
// mapped into `/Rect` the way the page maps display space into user space. Viewers
|
||||
// fit that box to `/Rect`; the page rotation then shows it the right way up.
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let turned = |mut stream: Stream| {
|
||||
if turn != 0 {
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
stream
|
||||
};
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
match name {
|
||||
@@ -643,28 +664,12 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
}
|
||||
let kind = crate::StampKind::from_name(other)?;
|
||||
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
|
||||
if turn != 0 {
|
||||
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
|
||||
// the page maps display space into user space. Viewers fit that box to
|
||||
// `/Rect`; the page rotation then shows the label the right way up.
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
return Some(stream);
|
||||
return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
|
||||
}
|
||||
}
|
||||
if turn != 0 {
|
||||
return Some(turned(form(draw, c.as_bytes(), res)));
|
||||
}
|
||||
}
|
||||
b"FreeText" => {
|
||||
let (text_color, size) = parse_da(d);
|
||||
|
||||
+182
-34
@@ -125,6 +125,14 @@ impl FillMark {
|
||||
FillMark::Line => "Line",
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred
|
||||
/// there and sized in displayed axes, so a line runs across the page as displayed (#2392).
|
||||
pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] {
|
||||
let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
|
||||
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
|
||||
@@ -386,7 +394,7 @@ pub enum Shape {
|
||||
by: Option<String>,
|
||||
},
|
||||
/// A typed signature or initials: filled outlines (each contour as points normalised to
|
||||
/// `rect`, 0–1, y up), drawn with the even-odd rule.
|
||||
/// `rect`, 0–1, y up), drawn with the non-zero winding rule.
|
||||
TypedSignature {
|
||||
rect: [f64; 4],
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
@@ -651,25 +659,54 @@ impl ReviewState {
|
||||
pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
|
||||
let root = doc.root().ok_or(AnnotError::NoPageTree)?;
|
||||
let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut stack = vec![pages];
|
||||
while let Some(node) = stack.pop() {
|
||||
if !seen.insert(node) || seen.len() > 1_000_000 {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
match d.get(b"Kids").map(|k| doc.resolve(k)) {
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => {
|
||||
if let Some(a) = kids.as_array() {
|
||||
stack.extend(a.iter().rev().filter_map(|k| k.as_ref()));
|
||||
}
|
||||
}
|
||||
_ => out.push(node),
|
||||
}
|
||||
Ok(PageWalk::new(doc, pages).collect())
|
||||
}
|
||||
|
||||
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
|
||||
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
|
||||
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
|
||||
/// Cycles end at the seen check and at most a million nodes are visited.
|
||||
struct PageWalk<'a> {
|
||||
doc: &'a Document,
|
||||
seen: std::collections::HashSet<ObjRef>,
|
||||
pending: Vec<(std::sync::Arc<Object>, usize)>,
|
||||
}
|
||||
|
||||
impl<'a> PageWalk<'a> {
|
||||
fn new(doc: &'a Document, top: ObjRef) -> Self {
|
||||
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
|
||||
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for PageWalk<'_> {
|
||||
type Item = ObjRef;
|
||||
|
||||
fn next(&mut self) -> Option<ObjRef> {
|
||||
while let Some((kids, next)) = self.pending.last_mut() {
|
||||
let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else {
|
||||
self.pending.pop();
|
||||
continue;
|
||||
};
|
||||
let node = kid.as_ref();
|
||||
*next += 1;
|
||||
let Some(node) = node else { continue };
|
||||
if self.seen.len() >= 1_000_000 {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
}
|
||||
if !self.seen.insert(node) {
|
||||
continue;
|
||||
}
|
||||
let obj = self.doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
|
||||
_ => return Some(node),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
|
||||
@@ -803,6 +840,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
|
||||
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
|
||||
}
|
||||
|
||||
/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without
|
||||
/// the page's content or other annotations, so a render draws that annotation alone: to lay it
|
||||
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
|
||||
/// normal appearance (`/AP /N`) for that render to draw.
|
||||
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let has_normal = {
|
||||
let obj = doc.resolve(&entry);
|
||||
obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")))
|
||||
};
|
||||
if !has_normal {
|
||||
return Ok(false);
|
||||
}
|
||||
set_annots(doc, p, vec![entry])?;
|
||||
doc.update_dict(p, |d| {
|
||||
d.remove(b"Contents");
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ── building ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Annotation flags (§12.5.3).
|
||||
@@ -1081,6 +1139,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name(mark.name()));
|
||||
border(&mut d);
|
||||
// Drawn upright for the page as displayed, as a stamp is (#2392).
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
Shape::Stamp { stamp, by, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
@@ -1355,8 +1418,22 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
|
||||
apd.remove(b"D");
|
||||
apd.remove(b"R");
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap));
|
||||
// A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`.
|
||||
// The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with
|
||||
// no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing.
|
||||
let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
|
||||
.then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
|
||||
.flatten()
|
||||
.filter(|q| q.len() == 4 && finite(q))
|
||||
.map(|q| {
|
||||
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
|
||||
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
|
||||
});
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"AP".to_vec(), Object::Dict(apd));
|
||||
if let Some(rect) = icon_rect {
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
}
|
||||
d.remove(b"AS");
|
||||
pdfcraft_fonts::set_undrawn_mark(d, missing);
|
||||
})?;
|
||||
@@ -1433,8 +1510,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
|
||||
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
|
||||
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
|
||||
let mut copy: Option<Document> = None;
|
||||
// An annotation listed more than once, on one page or several, is drawn once. `has_normal`
|
||||
// reads `doc`, which never gets the appearance, so every repeat used to add another stream to
|
||||
// the copy, and the display bytes grew with the number of listings.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for page in page_refs(doc).ok()? {
|
||||
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let d = annot_dict(doc, r);
|
||||
if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) {
|
||||
continue;
|
||||
@@ -1774,6 +1858,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
apply_text_box(doc, r, rect, meta, false)
|
||||
}
|
||||
|
||||
/// At most this many points reshape a comment (a drawn polygon has a handful).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
|
||||
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
|
||||
/// points with room for the line's width and endings, as when the comment was drawn, and the
|
||||
/// appearance is redrawn. A measurement keeps the points it measured.
|
||||
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.contains(b"Measure") {
|
||||
return Err(AnnotError::Invalid("a measurement's points are where it measured".into()));
|
||||
}
|
||||
if points.len() > MAX_POINTS {
|
||||
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
|
||||
}
|
||||
let style = Style { width: border_width_of(&d), ..Style::default() };
|
||||
let ends = line_endings_of(&d).unwrap_or_default();
|
||||
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
|
||||
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
|
||||
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
|
||||
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
|
||||
("Polygon", _) => {
|
||||
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
|
||||
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
|
||||
}
|
||||
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
|
||||
};
|
||||
// Checks the points too: finite, and far enough apart to draw.
|
||||
let rect = rect_for(&shape, &style)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(key.to_vec(), num_array(&points.concat()));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
|
||||
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
|
||||
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
@@ -1987,13 +2113,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
|
||||
/// ovals, polygons and clouds, §12.5.6.8–9).
|
||||
/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
|
||||
pub fn fillable(subtype: &str) -> bool {
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon")
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
|
||||
}
|
||||
|
||||
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
|
||||
/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and
|
||||
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
|
||||
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -2001,7 +2126,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new fill.
|
||||
if appearance::build(&d).is_none() {
|
||||
@@ -2013,10 +2138,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" };
|
||||
match fill {
|
||||
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
|
||||
Some(c) => d.set(key.to_vec(), rgb(c)),
|
||||
None => {
|
||||
d.remove(b"IC");
|
||||
d.remove(key);
|
||||
}
|
||||
}
|
||||
touch(d, meta);
|
||||
@@ -2082,6 +2208,9 @@ pub struct Summary {
|
||||
pub intent: Option<String>,
|
||||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||||
pub fill_sign: bool,
|
||||
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
|
||||
/// (`/Vertices`), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
@@ -2136,6 +2265,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
}
|
||||
}
|
||||
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
|
||||
// Keep in step with `pdfcraft_render::inspect`.
|
||||
let points = match subtype.as_str() {
|
||||
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
|
||||
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
out.push(Summary {
|
||||
page,
|
||||
@@ -2155,6 +2290,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
|
||||
fill_sign: is_fill_sign(doc, d),
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2205,7 +2341,7 @@ pub struct Props {
|
||||
pub subject: String,
|
||||
pub color: Option<Rgb>,
|
||||
pub opacity: f64,
|
||||
/// Border / line width, for shapes, lines and drawings.
|
||||
/// Border / line width, for shapes, lines, drawings and text boxes.
|
||||
pub width: Option<f64>,
|
||||
/// Notes only.
|
||||
pub icon: Option<NoteIcon>,
|
||||
@@ -2217,9 +2353,9 @@ pub struct Props {
|
||||
pub locked: bool,
|
||||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
|
||||
/// Whether the comment has a fill control (see [`fillable`]).
|
||||
pub fillable: bool,
|
||||
/// `/IC`, the interior colour (`None`: no fill).
|
||||
/// The interior colour (`None`: no fill).
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
@@ -2234,8 +2370,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
|
||||
.then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
|
||||
if subtype == "FreeText" {
|
||||
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
|
||||
} else {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
|
||||
}
|
||||
});
|
||||
Some(Props {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
@@ -2254,9 +2395,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
|
||||
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
|
||||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||||
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
|
||||
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let rgb = match c.as_slice() {
|
||||
[g] => [*g; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
@@ -2282,6 +2424,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,
|
||||
@@ -2292,6 +2439,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(
|
||||
@@ -2317,7 +2465,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(_) => {}
|
||||
|
||||
+198
-2
@@ -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]
|
||||
@@ -964,6 +991,29 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
|
||||
assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged");
|
||||
}
|
||||
|
||||
/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner
|
||||
/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it
|
||||
/// to nothing in one with no width.
|
||||
#[test]
|
||||
fn a_note_without_an_appearance_is_shown_at_icon_size() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>",
|
||||
b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("the notes get a display appearance");
|
||||
let rects = |doc: &Document| -> Vec<Vec<f64>> {
|
||||
let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::<Vec<f64>>());
|
||||
list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect()
|
||||
};
|
||||
assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]);
|
||||
let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]];
|
||||
assert_eq!(rects(&doc), original, "the source document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_line_ending_is_drawn() {
|
||||
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
|
||||
@@ -1274,6 +1324,28 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() {
|
||||
assert_eq!(a, b, "the reused appearance is byte-identical");
|
||||
}
|
||||
|
||||
/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in
|
||||
/// the display copy. Every listing used to add another appearance stream, so a short array of
|
||||
/// repeated references multiplied the display bytes the engine writes on each refresh.
|
||||
#[test]
|
||||
fn a_comment_listed_many_times_is_drawn_once_for_display() {
|
||||
let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" "));
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>",
|
||||
first.as_bytes(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>",
|
||||
]);
|
||||
let mut cache = AppearanceCache::default();
|
||||
let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance");
|
||||
assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 0));
|
||||
assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it");
|
||||
with_missing_appearances_cached(&doc, &mut cache).expect("a refresh");
|
||||
assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once");
|
||||
}
|
||||
|
||||
/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box
|
||||
/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused.
|
||||
#[test]
|
||||
@@ -1308,3 +1380,127 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() {
|
||||
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_and_callouts_can_change_fill_and_border() {
|
||||
let mut doc = fixture();
|
||||
let shapes = [
|
||||
Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 },
|
||||
Shape::Callout {
|
||||
rect: [300.0, 600.0, 500.0, 660.0],
|
||||
knee: [260.0, 560.0],
|
||||
point: [200.0, 520.0],
|
||||
font_size: 12.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
];
|
||||
for shape in shapes {
|
||||
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
|
||||
assert!(props(&doc, 0, index).unwrap().fillable);
|
||||
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
|
||||
set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
|
||||
let saved = reopen(&doc);
|
||||
let properties = props(&saved, 0, index).unwrap();
|
||||
assert_eq!(properties.fill, Some([1.0; 3]));
|
||||
assert_eq!(properties.width, Some(3.0));
|
||||
let ap = ap_content(&saved, &list(&saved, 0)[index]);
|
||||
assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}");
|
||||
set_fill(&mut doc, 0, index, None, &meta("test")).unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().fill, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_text_properties_show_legacy_or_absent_border_width() {
|
||||
let mut doc = fixture();
|
||||
let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap();
|
||||
let (_, reference) = annot_ref(&mut doc, 0, index).unwrap();
|
||||
doc.update_dict(reference, |d| {
|
||||
d.remove(b"BS");
|
||||
d.remove(b"Border");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0));
|
||||
doc.update_dict(reference, |d| {
|
||||
d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect()));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
|
||||
}
|
||||
|
||||
/// A line's ends and the corners of connected lines and polygons can be moved after drawing:
|
||||
/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is
|
||||
/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape.
|
||||
#[test]
|
||||
fn lines_and_polygons_are_reshaped_by_their_points() {
|
||||
let mut doc = fixture();
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = ap_content(&doc, &list(&doc, 0)[l]);
|
||||
set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[l];
|
||||
let ends: Vec<f64> = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]);
|
||||
let r = rect(d);
|
||||
assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}");
|
||||
assert!(r[2] - r[0] < 40.0, "and no more: {r:?}");
|
||||
assert_ne!(ap_content(&doc2, d), before, "drawn anew");
|
||||
assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]);
|
||||
// Two ends exactly, far enough apart.
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
// A polygon's corner.
|
||||
let g = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }),
|
||||
&meta("g"),
|
||||
)
|
||||
.unwrap();
|
||||
set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[g];
|
||||
let v: Vec<f64> = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(v.len(), 8);
|
||||
assert!(rect(d)[2] > 450.0);
|
||||
// Locked: refused, and nothing changes.
|
||||
set_locked(&mut doc, 0, l, true).unwrap();
|
||||
assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err());
|
||||
// Other comments have no points to move.
|
||||
let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap();
|
||||
assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
|
||||
let mut doc = fixture();
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 });
|
||||
// A cycle back to the root, then both pages 128 times over.
|
||||
let mut kids = vec![Object::Ref(root)];
|
||||
kids.extend([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(256));
|
||||
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
|
||||
let mut walk = PageWalk::new(&doc, root);
|
||||
assert_eq!(walk.next(), Some(first));
|
||||
assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len());
|
||||
assert_eq!(walk.next(), Some(second));
|
||||
assert_eq!(walk.next(), None);
|
||||
assert_eq!(page_refs(&doc).unwrap(), [first, second]);
|
||||
}
|
||||
|
||||
/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same
|
||||
/// winding direction where strokes cross, so typed signature appearances must fill with the
|
||||
/// non-zero winding operator (`f`), not even-odd (`f*`).
|
||||
#[test]
|
||||
fn typed_signature_uses_nonzero_winding_fill() {
|
||||
let mut doc = fixture();
|
||||
let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]];
|
||||
let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap();
|
||||
let ap = ap_content(&doc, &list(&doc, 0)[idx]);
|
||||
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
|
||||
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ It reports two kinds of finding:
|
||||
|
||||
- **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough
|
||||
against the text's colour (WCAG contrast below 3) to hide it, while the text is still
|
||||
extractable underneath;
|
||||
extractable underneath; opaque filled Square annotations count as covers too;
|
||||
- **unapplied mark**: a Redact annotation that was marked but never applied.
|
||||
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
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 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
|
||||
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:
|
||||
@@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden:
|
||||
- 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;
|
||||
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
|
||||
value in the middle of a long line) is not flagged.
|
||||
- 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".
|
||||
|
||||
+67
-14
@@ -12,8 +12,9 @@
|
||||
//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l`
|
||||
//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are
|
||||
//! considered when closed by a fill operator — other filled paths, shadings and patterns are
|
||||
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation
|
||||
//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign;
|
||||
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; opaque
|
||||
//! filled `/Square` annotations count as covers, but annotation rectangles are compared in
|
||||
//! unrotated user space, so pages with `/Rotate` may misalign;
|
||||
//! covers inside form XObjects are found, but only text drawn directly in the page content
|
||||
//! is checked under them, so text inside a form XObject is not audited.
|
||||
//!
|
||||
@@ -87,6 +88,9 @@ pub enum AuditError {
|
||||
/// 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;
|
||||
|
||||
@@ -111,11 +115,15 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
|
||||
}
|
||||
}
|
||||
// 2. Redact annotations that were never applied.
|
||||
// 2. Annotation covers over extractable text.
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if covers(&annot, line) {
|
||||
let hidden = match &annot {
|
||||
AnnotationCover::Redact(rect) => covers(rect, line),
|
||||
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
|
||||
};
|
||||
if hidden {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
@@ -123,7 +131,11 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered });
|
||||
let (kind, rect) = match annot {
|
||||
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
|
||||
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
|
||||
};
|
||||
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +150,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
|
||||
/// 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
|
||||
@@ -155,6 +170,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
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 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);
|
||||
@@ -182,9 +200,14 @@ fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots`
|
||||
/// means no marks; a malformed one fails the audit rather than silently passing.
|
||||
fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
|
||||
enum AnnotationCover {
|
||||
Redact([f64; 4]),
|
||||
Square { rect: [f64; 4], rgb: [f64; 3] },
|
||||
}
|
||||
|
||||
/// Opaque annotation covers on a page (unrotated user space). A missing `/Annots` means no
|
||||
/// covers; a malformed one fails the audit rather than silently passing.
|
||||
fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditError> {
|
||||
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
|
||||
let resolved = doc.resolve(a);
|
||||
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
|
||||
@@ -198,16 +221,46 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
|
||||
pdfcraft_cos::Object::Dict(d) => d.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
if dict.name(b"Subtype") != Some(b"Redact") {
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = match dict.get(b"Rect") {
|
||||
None => continue,
|
||||
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
|
||||
};
|
||||
if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
|
||||
out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]);
|
||||
if v.len() != 4 || !v.iter().all(|x| x.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
|
||||
match dict.name(b"Subtype") {
|
||||
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
|
||||
Some(b"Square") => {
|
||||
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if flags & 2 != 0 || flags & 32 != 0 {
|
||||
continue;
|
||||
}
|
||||
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
|
||||
if alpha >= 1.0
|
||||
&& let Some(rgb) = annotation_color(doc, &dict, b"IC")
|
||||
{
|
||||
out.push(AnnotationCover::Square { rect, rgb });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
|
||||
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
|
||||
if !values.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
match values.as_slice() {
|
||||
[g] => Some([g.clamp(0.0, 1.0); 3]),
|
||||
[r, g, b] => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
|
||||
[c, m, y, k] => {
|
||||
let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0));
|
||||
Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
+142
-7
@@ -175,6 +175,110 @@ 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 opaque_filled_square_annotation_over_text_is_found() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
assert_eq!(findings[0].rect, [95.0, 685.0, 325.0, 725.0]);
|
||||
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
||||
}
|
||||
|
||||
/// A Hidden Square (`/F 2`) is never drawn, so the text under it is not a finding.
|
||||
#[test]
|
||||
fn hidden_square_annotation_is_not_a_finding() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 2 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// A NoView Square (`/F 32`) is never drawn on screen, so the text under it is not a finding.
|
||||
#[test]
|
||||
fn no_view_square_annotation_is_not_a_finding() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 32 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// A Print-only Square (`/F 4`) is still drawn on screen, so the text under it is a finding.
|
||||
#[test]
|
||||
fn print_flag_square_annotation_is_still_a_finding() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 4 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
@@ -303,15 +407,17 @@ fn partial_value_is_found(rotated: bool) {
|
||||
/// Keep the historic detections with several covers, lines and text-spacing states.
|
||||
#[test]
|
||||
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
||||
let cases: [&[u8]; 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",
|
||||
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",
|
||||
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
|
||||
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
|
||||
// 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 in cases {
|
||||
for (content, expected) in cases {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,3 +563,32 @@ fn forms_nested_past_depth_limit_fail_closed() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
png = "0.18"
|
||||
base64 = { version = "0.22", optional = true }
|
||||
|
||||
|
||||
@@ -28,7 +27,11 @@ mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
png = "0.18"
|
||||
flate2.workspace = true
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
rustix = { version = "1", features = ["fs"] }
|
||||
|
||||
@@ -15,6 +15,12 @@ tools() -> Vec<ToolDef> // name, title, descrip
|
||||
mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JSON-RPC 2.0
|
||||
```
|
||||
|
||||
`content_update.rotate` rotates an added image counter-clockwise by an integer multiple of
|
||||
90 degrees. Large positive and negative signed 64-bit angles are normalized before combining
|
||||
them with the image's current orientation. Full turns retain its appearance, rectangle, crop
|
||||
and flips. Angles that are not multiples of 90 are refused without changing the document or
|
||||
its undo/redo history.
|
||||
|
||||
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
|
||||
|
||||
## Conventions
|
||||
@@ -64,6 +70,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
|
||||
@@ -75,7 +95,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
|
||||
@@ -108,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
|
||||
1. Add the engine capability first, with its tests (the tool is a thin adapter).
|
||||
2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists.
|
||||
3. Handle it in `Automation::call`, validating pages and positions with the existing helpers.
|
||||
4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
|
||||
4. Add an end-to-end test in `tests/it/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
|
||||
|
||||
|
||||
## Moving several page objects
|
||||
|
||||
@@ -710,9 +710,19 @@ impl Automation {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
|
||||
let bytes = self.doc_to_copy(a)?.export_office(format);
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
let truncated: Vec<usize> = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
|
||||
"truncated": !truncated.is_empty(), "truncated_pages": truncated });
|
||||
if !export.truncated_pages.is_empty() {
|
||||
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
out["warnings"] = json!([format!(
|
||||
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
|
||||
)]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pdfa_level(&self, a: &Args) -> Result<pdfcraft_engine::pdfa::Level> {
|
||||
@@ -918,11 +928,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() {
|
||||
|
||||
@@ -173,8 +173,7 @@ impl Automation {
|
||||
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
|
||||
let all = self.comments(a)?;
|
||||
let view = |c: &Annotation| -> Value {
|
||||
let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))];
|
||||
json!({
|
||||
let mut v = json!({
|
||||
"id": c.name,
|
||||
"page": c.page + 1,
|
||||
"index": c.index + 1,
|
||||
@@ -182,9 +181,20 @@ impl Automation {
|
||||
"author": c.author,
|
||||
"contents": c.contents,
|
||||
"modified": c.modified,
|
||||
"rect": rect_to_view(p, c.rect),
|
||||
// Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it.
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.rect),
|
||||
"color": c.color.map(hex),
|
||||
})
|
||||
});
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
// comment_edit's `points` takes them.
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
if !c.points.is_empty() {
|
||||
let round = |v: f32| (v * 100.0).round() / 100.0;
|
||||
let page = doc.info.pages.get(c.page);
|
||||
v["points"] =
|
||||
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
};
|
||||
let roots: Vec<Value> = all
|
||||
.iter()
|
||||
@@ -551,8 +561,7 @@ impl Automation {
|
||||
"line" => FillMark::Line,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))),
|
||||
};
|
||||
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
||||
(Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new())
|
||||
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
|
||||
}
|
||||
};
|
||||
let style = Style::default_for(&shape);
|
||||
@@ -589,7 +598,10 @@ impl Automation {
|
||||
pub(crate) fn comments_hide(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let hidden = a.opt_bool("hidden")?.unwrap_or(true);
|
||||
self.session.set_hide_comments(id, hidden);
|
||||
if self.session.set_hide_comments(id, hidden) {
|
||||
// The cached renderer drew with the old setting; page_render must read it again.
|
||||
self.renderers.remove(&id);
|
||||
}
|
||||
Ok(json!({ "hidden": hidden }))
|
||||
}
|
||||
|
||||
@@ -632,8 +644,23 @@ impl Automation {
|
||||
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
|
||||
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
|
||||
}
|
||||
if let Some(pts) = a.get("points") {
|
||||
let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into());
|
||||
let points = pts
|
||||
.as_array()
|
||||
.ok_or_else(wrong)?
|
||||
.iter()
|
||||
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
|
||||
_ => Err(wrong()),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
edits.push(Edit::ReshapeAnnotation { page, index, points });
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
|
||||
));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
self.apply(a, edit)
|
||||
|
||||
@@ -73,9 +73,9 @@ impl Automation {
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let index = per_page.entry(it.page).or_default();
|
||||
let ph = doc.info.pages[it.page].height as f64;
|
||||
let mut v =
|
||||
json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).map(|x| (x * 100.0).round() / 100.0) });
|
||||
// Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`.
|
||||
let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64));
|
||||
let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) });
|
||||
*index += 1;
|
||||
match &it.content {
|
||||
AddedContent::Text(t) => {
|
||||
@@ -138,7 +138,7 @@ impl Automation {
|
||||
.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))
|
||||
@@ -171,7 +171,8 @@ impl Automation {
|
||||
if deg.rem_euclid(90) != 0 {
|
||||
return Err(bad("rotate must be a multiple of 90"));
|
||||
}
|
||||
i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8;
|
||||
// Reduce untrusted degrees before adding the current quarter turns.
|
||||
i.rotation = (i64::from(i.rotation) + deg.rem_euclid(360) / 90).rem_euclid(4) as u8;
|
||||
}
|
||||
if a.opt_bool("flip_h")? == Some(true) {
|
||||
i.flip_h = !i.flip_h;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
|
||||
//! prepare a form (add, change and delete fields).
|
||||
//! prepare a form (add fields one at a time or as a table, change and delete them).
|
||||
|
||||
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
|
||||
use pdfcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
|
||||
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, failed};
|
||||
@@ -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<_>>()));
|
||||
}
|
||||
@@ -326,7 +339,71 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A table of text fields: `rect` (points from the top-left of the displayed page) divided
|
||||
/// into rows and columns, one field per cell, as one undo step.
|
||||
pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError};
|
||||
let page = self.page(a)?;
|
||||
let rect = a
|
||||
.get("rect")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|r| r.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
|
||||
.and_then(|r| <[f64; 4]>::try_from(r).ok())
|
||||
.ok_or_else(|| bad("rect must be 4 numbers"))?;
|
||||
let names = a.get("names").map(|_| a.strs("names")).transpose()?;
|
||||
let widths = match a.get("widths") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.and_then(|w| w.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
|
||||
.ok_or_else(|| bad("widths must be an array of numbers"))?,
|
||||
),
|
||||
};
|
||||
// A negative count is no count: the table refuses it with its own words.
|
||||
let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)];
|
||||
let mut given = counts.into_iter().flatten();
|
||||
let columns = given.next().unwrap_or(1);
|
||||
if given.any(|n| n != columns) {
|
||||
return Err(bad("columns, names and widths must agree on the number of columns"));
|
||||
}
|
||||
// Before anything is sized by it.
|
||||
if !(1..=MAX_COLUMNS).contains(&columns) {
|
||||
return Err(bad(TableError::Columns.to_string()));
|
||||
}
|
||||
let (edit, fields) = {
|
||||
let doc = self.doc(a)?;
|
||||
let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str()));
|
||||
let mut table = FieldTable::new(rect, &base);
|
||||
table.rows = usize::try_from(a.int("rows")?).unwrap_or(0);
|
||||
table.columns = (0..columns)
|
||||
.map(|c| TableColumn {
|
||||
pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()),
|
||||
width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0),
|
||||
})
|
||||
.collect();
|
||||
table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0);
|
||||
table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0);
|
||||
table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?;
|
||||
let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?;
|
||||
// The names the batch adds, in the order it adds them.
|
||||
let fields: Vec<String> = match &edit {
|
||||
Edit::Batch { edits, .. } => {
|
||||
edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(edit, fields)
|
||||
};
|
||||
let mut out = self.apply(a, edit)?;
|
||||
out["fields"] = json!(fields);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
|
||||
if a.get("widget").is_some() && a.get("caption").is_none() {
|
||||
return Err(bad("widget requires caption"));
|
||||
}
|
||||
let single = a.opt_str("field")?;
|
||||
let bulk = a.get("fields").is_some();
|
||||
if single.is_some() == bulk {
|
||||
@@ -344,7 +421,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")));
|
||||
}
|
||||
@@ -370,12 +447,24 @@ impl Automation {
|
||||
} else {
|
||||
edits.pop().ok_or_else(|| bad("give a field name"))?
|
||||
};
|
||||
// A rename also renames the field in the formulas that read it.
|
||||
let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None };
|
||||
let mut out = self.apply(a, edit)?;
|
||||
if bulk {
|
||||
out["fields"] = json!(names);
|
||||
} else {
|
||||
out["field"] = json!(new_name);
|
||||
}
|
||||
if let Some(before) = before {
|
||||
let after = &self.doc(a)?.form;
|
||||
// The renamed field itself, found by object: the name stored can differ from the
|
||||
// argument (trimmed, kept under its parent's prefix).
|
||||
let own =
|
||||
names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name);
|
||||
let mut updated = pdfcraft_engine::form_calculations_changed(&before, after);
|
||||
updated.retain(|f| Some(f) != own);
|
||||
out["formulas_updated"] = json!(updated);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -389,7 +478,7 @@ impl Automation {
|
||||
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
|
||||
let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?;
|
||||
let p = &doc.info.pages[page];
|
||||
let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?;
|
||||
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
|
||||
Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
|
||||
}
|
||||
@@ -398,6 +487,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")?,
|
||||
@@ -499,6 +597,54 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result<Value> {
|
||||
let current = self.doc(a)?.calculation_order();
|
||||
let fix = a.opt_bool("fix")?.unwrap_or(false);
|
||||
let field = a.opt_str("field")?;
|
||||
let order = a.get("order").map(|_| a.strs("order")).transpose()?;
|
||||
if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 {
|
||||
return Err(bad("give one change at a time: order, field with move, or fix"));
|
||||
}
|
||||
let names: Option<Vec<String>> = if let Some(order) = order {
|
||||
Some(order.into_iter().map(str::to_owned).collect())
|
||||
} else if let Some(field) = field {
|
||||
let earlier = match a.opt_str("move")?.unwrap_or("earlier") {
|
||||
"earlier" => true,
|
||||
"later" => false,
|
||||
m => return Err(bad(format!("move must be earlier or later, not {m:?}"))),
|
||||
};
|
||||
let mut names: Vec<String> = current.fields.iter().map(|f| f.name.clone()).collect();
|
||||
let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?;
|
||||
// Already first or last: nothing moves.
|
||||
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) };
|
||||
j.map(|j| {
|
||||
names.swap(i, j);
|
||||
names
|
||||
})
|
||||
} else {
|
||||
(fix && current.fixable()).then(|| current.fixed())
|
||||
};
|
||||
let mut out = match names {
|
||||
Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?,
|
||||
None => json!({}),
|
||||
};
|
||||
let order = self.doc(a)?.calculation_order();
|
||||
let issue = |i: &CalculationIssue| match i {
|
||||
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
|
||||
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
|
||||
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
|
||||
};
|
||||
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
|
||||
out["fields"] = order
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
out["extra"] = json!(order.extra);
|
||||
out["fixable"] = json!(order.fixable());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn tab_sequence(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let mut seq: Vec<(usize, &str)> =
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3).
|
||||
//!
|
||||
//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file
|
||||
//! itself never changes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError};
|
||||
|
||||
impl Automation {
|
||||
/// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based
|
||||
/// position in that list. A name picks every layer that has it, since CAD exporters often
|
||||
/// write one group per page or per drawing under the same name.
|
||||
pub(crate) fn layer_set(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?;
|
||||
let layers = &doc.info.layers;
|
||||
let targets: Vec<usize> = match a.get("layer") {
|
||||
Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(),
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize],
|
||||
Some(_) => Vec::new(),
|
||||
None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())),
|
||||
},
|
||||
None => return Err(ToolError::InvalidArgs("missing argument layer".into())),
|
||||
};
|
||||
if targets.is_empty() {
|
||||
let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
|
||||
return Err(ToolError::InvalidArgs(if names.is_empty() {
|
||||
"the document has no layers".into()
|
||||
} else {
|
||||
format!("no such layer; the document's layers are: {}", names.join(", "))
|
||||
}));
|
||||
}
|
||||
let mut changed = false;
|
||||
for i in targets {
|
||||
changed |= self.session.set_layer_visible(id, i, visible);
|
||||
}
|
||||
if changed {
|
||||
// The cached renderer drew with the old visibility; page_render must read it again.
|
||||
self.renderers.remove(&id);
|
||||
}
|
||||
let layers = &self.doc(a)?.info.layers;
|
||||
Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>() }))
|
||||
}
|
||||
}
|
||||
+210
-54
@@ -21,6 +21,7 @@ mod content;
|
||||
mod conventions;
|
||||
pub mod file_access;
|
||||
mod forms;
|
||||
mod layers;
|
||||
mod links;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod mcp;
|
||||
@@ -32,11 +33,11 @@ mod signing;
|
||||
mod text_cache;
|
||||
mod tools;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands};
|
||||
use pdfcraft_engine::{BlockKey, CombineSource, DocId, Document, Edit, Session, TextBlock, block_overlapping, commands};
|
||||
use pdfcraft_platform::staging::{StagingName, staging_suffixes};
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use serde_json::{Value, json};
|
||||
@@ -96,6 +97,9 @@ const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
|
||||
/// Bookmarks in one page of the bookmark list, by default and at most.
|
||||
const BOOKMARK_PAGE: usize = 100;
|
||||
const MAX_BOOKMARK_PAGE: usize = 1000;
|
||||
/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
|
||||
/// most 14 400 across).
|
||||
const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
|
||||
|
||||
/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a
|
||||
/// match near the start returns without touching the rest of a long document (#971).
|
||||
@@ -118,6 +122,12 @@ pub struct Automation {
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
|
||||
/// Extracted page text, under one memory budget across all open documents.
|
||||
texts: TextCache,
|
||||
/// The paragraphs of the last `text_paragraphs` listing of each page, so a paragraph number
|
||||
/// keeps naming that paragraph after edits regroup the page (#2516).
|
||||
listed: HashMap<(DocId, usize), Vec<BlockKey>>,
|
||||
/// Documents that lost a listing to the cap. A page of one with no listing is refused rather
|
||||
/// than read as "a number is a position", which the page may no longer match.
|
||||
evicted: HashSet<DocId>,
|
||||
}
|
||||
|
||||
impl Default for Automation {
|
||||
@@ -128,7 +138,15 @@ impl Default for Automation {
|
||||
|
||||
impl Automation {
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
Self {
|
||||
session: Session::new(),
|
||||
root: None,
|
||||
roots: None,
|
||||
renderers: HashMap::new(),
|
||||
texts: TextCache::default(),
|
||||
listed: HashMap::new(),
|
||||
evicted: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
|
||||
@@ -283,6 +301,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)?,
|
||||
@@ -332,7 +357,13 @@ impl Automation {
|
||||
let parent = a.opt_path("parent")?.unwrap_or_default();
|
||||
let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize);
|
||||
let title = a.str("title")?.to_string();
|
||||
self.apply(&a, Edit::AddBookmark { parent, index, title, page })?
|
||||
let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?);
|
||||
let [left, top] = match self.doc(&a)?.info.pages.get(page) {
|
||||
Some(p) => p.xyz_for_view(left, top),
|
||||
None => [None, None],
|
||||
};
|
||||
let (left, top) = (left.map(f64::from), top.map(f64::from));
|
||||
self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })?
|
||||
}
|
||||
"bookmark_rename" => {
|
||||
let (path, title) = (a.path("path")?, a.str("title")?.to_string());
|
||||
@@ -373,7 +404,8 @@ 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)?,
|
||||
@@ -491,6 +523,11 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"rotate_by" => ImageEdit::RotateBy(
|
||||
a.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?,
|
||||
),
|
||||
"rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32),
|
||||
"crop" => {
|
||||
let r: Vec<f64> =
|
||||
@@ -522,8 +559,9 @@ impl Automation {
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let blocks: Vec<Value> = doc
|
||||
.text_blocks(page)
|
||||
let current = doc.text_blocks(page);
|
||||
let id = doc.id;
|
||||
let blocks: Vec<Value> = current
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| {
|
||||
@@ -540,6 +578,7 @@ impl Automation {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.record_listing(id, page, ¤t);
|
||||
json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks })
|
||||
}
|
||||
"text_font_files" => {
|
||||
@@ -597,10 +636,8 @@ impl Automation {
|
||||
let doc = self.doc(&a)?;
|
||||
let k = a.int("paragraph")?;
|
||||
let blocks = doc.text_blocks(page);
|
||||
if k < 1 || k as usize > blocks.len() {
|
||||
return Err(ToolError::InvalidArgs("paragraph is out of range".into()));
|
||||
}
|
||||
let source = &blocks[k as usize - 1];
|
||||
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
|
||||
let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?;
|
||||
let text = a.opt_str("text")?.unwrap_or(&source.editor_text);
|
||||
let style = pdfcraft_engine::BlockStyle {
|
||||
size: a.opt_num("size")?,
|
||||
@@ -609,7 +646,7 @@ impl Automation {
|
||||
ranges: parse_style_ranges(a.get("ranges"))?,
|
||||
..Default::default()
|
||||
};
|
||||
let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?;
|
||||
let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let view_rect = |r: [f64; 4]| {
|
||||
let a = info.user_to_view(r[0] as f32, r[1] as f32);
|
||||
@@ -622,12 +659,13 @@ impl Automation {
|
||||
}
|
||||
"text_edit" if a.get("paragraph").is_some() => {
|
||||
let page = self.page(&a)?;
|
||||
let n = self.doc(&a)?.text_blocks(page).len();
|
||||
let k = a.int("paragraph")?;
|
||||
if k < 1 || k as usize > n {
|
||||
return Err(ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)));
|
||||
}
|
||||
let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone();
|
||||
let (id, blocks) = {
|
||||
let doc = self.doc(&a)?;
|
||||
(doc.id, doc.text_blocks(page))
|
||||
};
|
||||
let index = self.paragraph_index(id, page, k, &blocks)?;
|
||||
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
|
||||
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
|
||||
let mut style = pdfcraft_engine::BlockStyle {
|
||||
ranges: parse_style_ranges(a.get("ranges"))?,
|
||||
@@ -666,10 +704,25 @@ impl Automation {
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
|
||||
});
|
||||
}
|
||||
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: k as usize - 1, text, style })?;
|
||||
if let Some(b) = self.doc(&a)?.text_blocks(page).get(k as usize - 1) {
|
||||
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
|
||||
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
|
||||
let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy];
|
||||
let wanted: String = text.split_whitespace().collect();
|
||||
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?;
|
||||
let after = self.doc(&a)?.text_blocks(page);
|
||||
// Only a paragraph that now shows exactly the requested text is the edited one. A
|
||||
// neighbour that overlaps the old box (overlaid text, a text layer) must not take
|
||||
// its number, and neither must a paragraph the edit merged it into: rewriting that
|
||||
// one by this number would drop the neighbour's text.
|
||||
let edited = block_overlapping(&after, moved)
|
||||
.filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::<String>() == wanted));
|
||||
if let Some(b) = edited.and_then(|i| after.get(i)) {
|
||||
out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font });
|
||||
}
|
||||
let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after);
|
||||
if !stale.is_empty() {
|
||||
out["stale_paragraphs"] = json!(stale);
|
||||
}
|
||||
out
|
||||
}
|
||||
"text_edit" => {
|
||||
@@ -718,9 +771,11 @@ impl Automation {
|
||||
}
|
||||
"form_reset" => self.form_reset(&a)?,
|
||||
"form_add_field" => self.form_add_field(&a)?,
|
||||
"form_add_field_table" => self.form_add_field_table(&a)?,
|
||||
"form_set_props" => self.form_set_props(&a)?,
|
||||
"form_delete_field" => self.form_delete_field(&a)?,
|
||||
"form_tab_order" => self.form_tab_order(&a)?,
|
||||
"form_calculation_order" => self.form_calculation_order(&a)?,
|
||||
"doc_export_data" => self.doc_export_data(&a)?,
|
||||
"doc_import_data" => self.doc_import_data(&a)?,
|
||||
"redact_mark" => self.redact_mark(&a)?,
|
||||
@@ -782,6 +837,14 @@ impl Automation {
|
||||
"print_readiness" => self.print_readiness(&a)?,
|
||||
"doc_export_office" => self.doc_export_office(&a)?,
|
||||
"pdfa_convert" => self.pdfa_convert(&a)?,
|
||||
"standards_remove_id" => {
|
||||
let standard = match a.str("standard")? {
|
||||
"pdfa" => pdfcraft_engine::Standard::PdfA,
|
||||
"pdfx" => pdfcraft_engine::Standard::PdfX,
|
||||
other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))),
|
||||
};
|
||||
self.apply(&a, Edit::RemoveStandardId { standard })?
|
||||
}
|
||||
"action_run" => self.action_run(&a)?,
|
||||
"doc_compare_report" => self.doc_compare_report(&a)?,
|
||||
"doc_compare_mark" => self.doc_compare_mark(&a)?,
|
||||
@@ -838,6 +901,7 @@ impl Automation {
|
||||
"comment_mark" => self.comment_mark(&a)?,
|
||||
"comment_lock" => self.comment_lock(&a)?,
|
||||
"comments_hide" => self.comments_hide(&a)?,
|
||||
"layer_set" => self.layer_set(&a)?,
|
||||
"view_reading_colors" => self.view_reading_colors(&a)?,
|
||||
"comments_summarize" => self.comments_summarize(&a)?,
|
||||
"comment_edit" => self.comment_edit(&a)?,
|
||||
@@ -874,6 +938,59 @@ impl Automation {
|
||||
self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)")))
|
||||
}
|
||||
|
||||
/// Most pages whose paragraph listing is remembered.
|
||||
const MAX_LISTINGS: usize = 256;
|
||||
|
||||
/// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be
|
||||
/// resolved against that listing.
|
||||
fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) {
|
||||
if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) {
|
||||
self.evicted.extend(self.listed.keys().map(|(d, _)| *d));
|
||||
self.listed.clear();
|
||||
}
|
||||
self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect());
|
||||
}
|
||||
|
||||
/// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based).
|
||||
///
|
||||
/// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is
|
||||
/// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a
|
||||
/// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph
|
||||
/// and lose its text (#2516). So is any number on a page of a document whose listings were
|
||||
/// dropped at the cap, until the page is listed again.
|
||||
fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result<usize> {
|
||||
let keys = self.listed.get(&(doc, page));
|
||||
if keys.is_none() && self.evicted.contains(&doc) {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs",
|
||||
page + 1
|
||||
)));
|
||||
}
|
||||
let n = keys.map_or(blocks.len(), Vec::len);
|
||||
let i = usize::try_from(k)
|
||||
.ok()
|
||||
.and_then(|k| k.checked_sub(1))
|
||||
.filter(|i| *i < n)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?;
|
||||
let Some(keys) = keys else { return Ok(i) };
|
||||
keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| {
|
||||
ToolError::InvalidArgs(format!(
|
||||
"paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs",
|
||||
page + 1
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the listing after paragraph `k` was edited into `edited`, one of the page's
|
||||
/// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone.
|
||||
fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec<usize> {
|
||||
let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() };
|
||||
if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) {
|
||||
*key = BlockKey::of(edited);
|
||||
}
|
||||
keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect()
|
||||
}
|
||||
|
||||
/// The document, if its security allows copying text and images out of it (Table 22, bit 5).
|
||||
fn doc_to_copy(&self, a: &Args) -> Result<&Document> {
|
||||
let doc = self.doc(a)?;
|
||||
@@ -900,6 +1017,8 @@ impl Automation {
|
||||
self.session.close(id);
|
||||
self.renderers.remove(&id);
|
||||
self.texts.remove(&id);
|
||||
self.listed.retain(|(d, _), _| *d != id);
|
||||
self.evicted.remove(&id);
|
||||
Ok(json!({ "closed": id.0 }))
|
||||
}
|
||||
|
||||
@@ -1191,6 +1310,14 @@ impl Automation {
|
||||
if let Some(s) = a.opt_num("font_size")? {
|
||||
hf.font_size = s;
|
||||
}
|
||||
hf.font = match a.opt_str("font")? {
|
||||
None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica,
|
||||
Some("Times") => pdfcraft_engine::FontFamily::Times,
|
||||
Some("Courier") => pdfcraft_engine::FontFamily::Courier,
|
||||
Some(other) => {
|
||||
return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}")));
|
||||
}
|
||||
};
|
||||
hf.color = color("color", hf.color)?;
|
||||
if let Some(m) = a.get("margins").and_then(Value::as_array) {
|
||||
let m: Vec<f64> = m.iter().filter_map(Value::as_f64).collect();
|
||||
@@ -1199,6 +1326,7 @@ impl Automation {
|
||||
if let Some(s) = a.opt_int("start_number")? {
|
||||
hf.start_number = s.clamp(1, u32::MAX as i64) as u32;
|
||||
}
|
||||
hf.shrink = a.opt_bool("shrink")?.unwrap_or(false);
|
||||
Edit::AddHeaderFooter { pages, settings: hf, replace }
|
||||
}
|
||||
"doc_watermark" => {
|
||||
@@ -1746,16 +1874,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)?;
|
||||
@@ -1781,21 +1914,26 @@ 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> {
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
|
||||
let colors = self.session.reading_colors(id).unwrap_or_default();
|
||||
let fresh = || {
|
||||
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() };
|
||||
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
|
||||
};
|
||||
// The document's own view settings: layer visibility, hidden comments, reading colours
|
||||
// (tools that change one drop this cache so the next render reads it again).
|
||||
let config =
|
||||
self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() });
|
||||
let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone()));
|
||||
let entry = self.renderers.entry(id).or_insert_with(fresh);
|
||||
if !Arc::ptr_eq(&entry.0, &doc.display) {
|
||||
*entry = fresh();
|
||||
@@ -1817,11 +1955,13 @@ impl Automation {
|
||||
return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into()));
|
||||
}
|
||||
let out = if let Some(block) = block {
|
||||
let index = usize::try_from(block)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
|
||||
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
if block < 1 {
|
||||
return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()));
|
||||
}
|
||||
// The number names the paragraph text_paragraphs listed, as it does for text_edit.
|
||||
let doc = self.doc(a)?;
|
||||
let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?;
|
||||
doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
} else if let Some(added) = added {
|
||||
let index = usize::try_from(added)
|
||||
.ok()
|
||||
@@ -2269,6 +2409,15 @@ impl Args<'_> {
|
||||
let Some(v) = self.opt_ints(key)? else { return Ok(None) };
|
||||
v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
/// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error
|
||||
/// when it is not a number or lies absurdly far off the page.
|
||||
fn bookmark_coordinate(&self, key: &str) -> Result<Option<f32>> {
|
||||
match self.opt_num(key)? {
|
||||
None => Ok(None),
|
||||
Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)),
|
||||
Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))),
|
||||
}
|
||||
}
|
||||
fn strs(&self, key: &str) -> Result<Vec<&str>> {
|
||||
let v = self.get(key).ok_or_else(|| Self::missing(key))?;
|
||||
let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?;
|
||||
@@ -2394,6 +2543,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,
|
||||
@@ -2451,7 +2603,7 @@ fn info(d: &Document) -> Value {
|
||||
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
|
||||
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
|
||||
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
|
||||
"transitions": i.transitions.iter().map(|t| json!({
|
||||
@@ -2485,24 +2637,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
|
||||
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
|
||||
}
|
||||
|
||||
/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same).
|
||||
fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut rgba = premultiplied.to_vec();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let a = u32::from(px[3]);
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, width, height);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut w = enc.write_header().map_err(failed)?;
|
||||
w.write_image_data(&rgba).map_err(failed)?;
|
||||
w.finish().map_err(failed)?;
|
||||
Ok(out)
|
||||
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
|
||||
}
|
||||
|
||||
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
|
||||
@@ -3101,4 +3238,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +70,8 @@ impl Automation {
|
||||
.links()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let p = &doc.info.pages[l.page];
|
||||
let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32));
|
||||
let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0);
|
||||
// The link's page may be one the viewer could not read (#2660): rect unchanged then.
|
||||
let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32));
|
||||
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
|
||||
// Where an in-document link goes, as the viewer resolves it: named destinations
|
||||
// included, with the position on the page.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::{Args, Automation, Result, ToolError, failed};
|
||||
use pdfcraft_engine::{
|
||||
Edit, Style,
|
||||
measure::{self, Kind, NewMeasurement, Scale},
|
||||
measure::{self, Kind, NewMeasurement, PageUnit, Scale},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -78,16 +78,30 @@ impl Automation {
|
||||
let info = self.doc(a)?.info.pages.get(page).ok_or_else(|| failed("no such page"))?.clone();
|
||||
let precision = u8::try_from(a.opt_int("precision")?.unwrap_or(2)).map_err(|_| failed("precision must be between 0 and 6"))?;
|
||||
let unit = a.opt_str("unit")?.unwrap_or("in");
|
||||
if ["points", "units_per_point", "page_distance"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
|
||||
return Err(failed("pass one of calibration points, units_per_point or page_distance"));
|
||||
}
|
||||
if a.get("page_unit").is_some() && a.get("page_distance").is_none() {
|
||||
return Err(failed("page_unit is the unit of page_distance; pass page_distance too"));
|
||||
}
|
||||
let scale = if a.get("points").is_some() {
|
||||
if a.get("units_per_point").is_some() {
|
||||
return Err(failed("pass calibration points or units_per_point, not both"));
|
||||
}
|
||||
let (_, points) = self.measurement_points(a)?;
|
||||
let [from, to] = points.as_slice() else {
|
||||
return Err(failed("scale calibration needs exactly two points"));
|
||||
};
|
||||
let length = a.opt_num("distance")?.ok_or_else(|| failed("calibration needs a real-world distance"))?;
|
||||
Scale::calibrate(*from, *to, length, unit, precision).map_err(failed)?
|
||||
} else if let Some(page_distance) = a.opt_num("page_distance")? {
|
||||
// A ratio, "page_distance page_unit on the page = distance unit"; the page unit
|
||||
// defaults to the real-world one, as "1:50" means 1 mm = 50 mm.
|
||||
let page_unit = match a.opt_str("page_unit")? {
|
||||
Some(u) => PageUnit::from_label(u).ok_or_else(|| failed("page_unit must be pt, in, mm or cm"))?,
|
||||
None => PageUnit::from_label(unit).ok_or_else(|| failed("pass page_unit (pt, in, mm or cm) when unit is not one of them"))?,
|
||||
};
|
||||
let length = a.opt_num("distance")?.ok_or_else(|| failed("a scale ratio needs the real-world distance"))?;
|
||||
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
|
||||
let to = self.doc(a)?.measurement_to_user(page, [1.0, 0.0]).map_err(failed)?;
|
||||
Scale::ratio(from, to, page_distance, page_unit, length, unit, precision).map_err(failed)?
|
||||
} else if let Some(factor) = a.opt_num("units_per_point")? {
|
||||
{
|
||||
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
|
||||
|
||||
@@ -6,6 +6,22 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
|
||||
|
||||
/// The job's two-sided setting for `doc_print`'s `duplex`: `printer` sends none, so the printer's
|
||||
/// own default applies (as the Print dialog's Printer default, #2422), except that cut and stack
|
||||
/// needs single-sided sheets.
|
||||
fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
|
||||
Ok(match value {
|
||||
"printer" if cut_stack => Some(spool::Duplex::Off),
|
||||
"printer" => None,
|
||||
"off" => Some(spool::Duplex::Off),
|
||||
"long-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")),
|
||||
"short-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")),
|
||||
"long-edge" => Some(spool::Duplex::LongEdge),
|
||||
"short-edge" => Some(spool::Duplex::ShortEdge),
|
||||
d => return Err(bad(format!("unknown duplex {d:?}"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn bad(m: impl Into<String>) -> ToolError {
|
||||
ToolError::InvalidArgs(m.into())
|
||||
}
|
||||
@@ -92,9 +108,7 @@ impl Automation {
|
||||
},
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
};
|
||||
if matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }) && a.opt_str("duplex")?.unwrap_or("off") != "off" {
|
||||
return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)"));
|
||||
}
|
||||
let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
|
||||
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
|
||||
return Err(bad("cut-stack order needs layout multiple"));
|
||||
}
|
||||
@@ -153,12 +167,7 @@ impl Automation {
|
||||
printer: if printer == "default" { None } else { Some(printer.to_string()) },
|
||||
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") {
|
||||
"off" => spool::Duplex::Off,
|
||||
"long-edge" => spool::Duplex::LongEdge,
|
||||
"short-edge" => spool::Duplex::ShortEdge,
|
||||
d => return Err(bad(format!("unknown duplex {d:?}"))),
|
||||
}),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
title: name,
|
||||
@@ -181,3 +190,21 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks
|
||||
/// for single-sided sheets, and refuses an explicit edge.
|
||||
#[test]
|
||||
fn duplex_printer_leaves_two_sided_to_the_printer() {
|
||||
assert_eq!(duplex("printer", false).ok(), Some(None));
|
||||
assert_eq!(duplex("printer", true).ok(), Some(Some(spool::Duplex::Off)));
|
||||
assert_eq!(duplex("off", false).ok(), Some(Some(spool::Duplex::Off)));
|
||||
assert_eq!(duplex("long-edge", false).ok(), Some(Some(spool::Duplex::LongEdge)));
|
||||
assert_eq!(duplex("short-edge", false).ok(), Some(Some(spool::Duplex::ShortEdge)));
|
||||
assert!(duplex("long-edge", true).is_err());
|
||||
assert!(duplex("sideways", false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,19 @@ impl Automation {
|
||||
pub(crate) fn redact_mark(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, n) = (doc.id, doc.info.pages.len());
|
||||
let pages: Vec<usize> = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
if a.get("page").is_some() && a.get("pages").is_some() {
|
||||
return Err(ToolError::InvalidArgs("pass page or pages, not both".into()));
|
||||
}
|
||||
if a.get("rect").is_some() && a.get("pages").is_some() {
|
||||
return Err(ToolError::InvalidArgs("rect requires page, not pages".into()));
|
||||
}
|
||||
let pages: Vec<usize> = if a.get("page").is_some() {
|
||||
vec![self.page(a)?]
|
||||
} else {
|
||||
match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
}
|
||||
};
|
||||
let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) {
|
||||
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())),
|
||||
|
||||
@@ -208,7 +208,7 @@ impl Automation {
|
||||
};
|
||||
let rect = match a.nums::<4>("rect")? {
|
||||
Some(r) => {
|
||||
let p = &doc.info.pages[page];
|
||||
let p = doc.info.pages.get(page).ok_or_else(|| bad("the document has no page to put the signature on"))?;
|
||||
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
|
||||
let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64];
|
||||
if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 {
|
||||
|
||||
@@ -156,12 +156,12 @@ 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 (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
|
||||
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
@@ -224,6 +224,9 @@ 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(
|
||||
@@ -286,8 +289,21 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema(
|
||||
json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }),
|
||||
t(
|
||||
"bookmark_add",
|
||||
"Add a bookmark",
|
||||
"Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. It goes to the top of the page, or to a place on it: `top` and `left` are points from the top-left of the displayed page (like the destination bookmark_list reports), each optional, and a coordinate left out keeps the reader's position that way. The zoom is kept. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"parent": path("Parent bookmark (omit for the top level)"),
|
||||
"position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
|
||||
"top": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the top of the displayed page." },
|
||||
"left": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the left of the displayed page." }
|
||||
}),
|
||||
&["doc", "title", "page"],
|
||||
)),
|
||||
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
|
||||
@@ -318,7 +334,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(
|
||||
@@ -352,6 +368,27 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "type", "rect"],
|
||||
)),
|
||||
t(
|
||||
"form_add_field_table",
|
||||
"Prepare form: add a table of text fields",
|
||||
"Divide rect into rows and columns of text fields, one per cell, as one undo step. rect in points from the top-left of the displayed page [x0, y0, x1, y1]; rows 1-100 from the top; columns 1-50, or as many as names or widths list. Each column names its cells from its pattern in names: an N on its own after _ - / or : (price_N), # or {row} is the row number, {col} the column number, {seq} counts every cell along the rows, starting at first_row; with none of them the row number goes at the end. Rows count from first_row (default 1). Without names the columns are Table1_N, Table1_c2_N, … using the first free TableK. widths are relative (default equal); row_gap and column_gap are points between cells (default 0). Nothing is added if a cell would be under 4 points or a name would contain a period, repeat, or already exist; the error says which. Returns the fields' names. Undoable.",
|
||||
)
|
||||
.cmd("form.add.text_table")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"rows": { "type": "integer", "minimum": 1, "maximum": 100 },
|
||||
"columns": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||||
"names": { "type": "array", "items": { "type": "string" }, "description": "One name pattern per column." },
|
||||
"widths": { "type": "array", "items": { "type": "number", "exclusiveMinimum": 0 }, "description": "Relative column widths, one per column." },
|
||||
"row_gap": { "type": "number", "minimum": 0 },
|
||||
"column_gap": { "type": "number", "minimum": 0 },
|
||||
"first_row": { "type": "integer", "minimum": 0, "maximum": 99999 },
|
||||
}),
|
||||
&["doc", "page", "rect", "rows"],
|
||||
)),
|
||||
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. name labels it (default: the file name). Undoable.")
|
||||
.cmd("comment.stamp")
|
||||
.with(schema(
|
||||
@@ -371,7 +408,7 @@ 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. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -380,6 +417,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"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" },
|
||||
@@ -405,6 +444,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
|
||||
.cmd("form.calculation_order")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
@@ -416,12 +461,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"redact_mark",
|
||||
"Mark for redaction",
|
||||
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
|
||||
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"page": { "type": "integer", "minimum": 1, "description": "Single page to mark; mutually exclusive with pages." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"find": { "type": "string", "minLength": 1 },
|
||||
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
|
||||
@@ -429,7 +474,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
|
||||
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
|
||||
"whole_pages": { "type": "boolean" },
|
||||
"pages": pages("to search or mark (default: all)"),
|
||||
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
|
||||
"overlay": { "type": "string" },
|
||||
"code_set": { "type": "string", "enum": ["foia", "privacy-act"] },
|
||||
"codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." },
|
||||
@@ -482,7 +527,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5/A6 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -507,7 +552,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." },
|
||||
"grayscale": { "type": "boolean" },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
|
||||
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
|
||||
@@ -611,8 +656,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro().cmd("measure.info").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"type":{"type":"string","enum":["distance","perimeter","area"]}}), &["doc","page","points"])),
|
||||
t("measure_list", "List measurements", "Saved measurement annotations with calculated values, scale and vertices in display coordinates. Measurements with unsupported imported formats (compound or fractional units, non-rectilinear scales) or invalid geometry are listed under unsupported with a reason.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc"])),
|
||||
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
|
||||
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
|
||||
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using a ratio (page_distance in page_unit on the page = distance in unit, e.g. 1 mm = 50 mm for a 1:50 plan; page_unit defaults to unit), units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
|
||||
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"page_distance":{"type":"number","exclusiveMinimum":0},"page_unit":{"type":"string","enum":["pt","in","mm","cm"]},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
|
||||
t("measure_snap", "Snap a measurement vertex", "Snap a point to vector paths, endpoints, midpoints or intersections. Coordinates and tolerance are in display points. Bounded extraction reports truncated geometry.")
|
||||
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
|
||||
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
|
||||
@@ -745,7 +790,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
)),
|
||||
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
|
||||
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, a .p7b/.p7c bundle, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
|
||||
json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }),
|
||||
&[],
|
||||
)),
|
||||
@@ -757,6 +802,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })),
|
||||
&["doc"],
|
||||
)),
|
||||
t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }),
|
||||
&["doc", "layer", "visible"],
|
||||
)),
|
||||
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
|
||||
.cmd("comment.hide_all")
|
||||
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
|
||||
@@ -779,16 +829,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"width": { "type": "number", "minimum": 0 },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
|
||||
})),
|
||||
&["doc"],
|
||||
)),
|
||||
@@ -822,9 +873,9 @@ 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",
|
||||
@@ -864,7 +915,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_header_footer",
|
||||
"Add header and footer",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. replace: true swaps out existing ones (Update). Undoable.",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
|
||||
)
|
||||
.cmd("edit.header_footer")
|
||||
.with(schema(
|
||||
@@ -873,10 +924,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"pages": pages("to mark (default: all)"),
|
||||
"header_left": { "type": "string" }, "header_center": { "type": "string" }, "header_right": { "type": "string" },
|
||||
"footer_left": { "type": "string" }, "footer_center": { "type": "string" }, "footer_right": { "type": "string" },
|
||||
"font": { "type": "string", "enum": ["Helvetica", "Times", "Courier"] },
|
||||
"font_size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"color": { "type": "string", "description": "#RRGGBB or a colour name." },
|
||||
"margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." },
|
||||
"start_number": { "type": "integer", "minimum": 1 },
|
||||
"shrink": { "type": "boolean", "description": "Shrink the page content so the header and footer don't cover it (default false)." },
|
||||
"replace": { "type": "boolean" },
|
||||
}),
|
||||
&["doc"],
|
||||
@@ -1060,7 +1113,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
@@ -1073,6 +1126,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
|
||||
.cmd("standards.pdfa")
|
||||
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
|
||||
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])),
|
||||
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
|
||||
.ro()
|
||||
.cmd("actions.wizard")
|
||||
@@ -1098,7 +1153,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; 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).")
|
||||
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).")
|
||||
@@ -1241,16 +1296,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"degrees": { "type": "number" },
|
||||
"path": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "image", "action"],
|
||||
@@ -1268,7 +1324,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
|
||||
.ro().cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
|
||||
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.")
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
@@ -1276,7 +1332,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
|
||||
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
|
||||
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
|
||||
|
||||
+1677
-14
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -336,3 +336,146 @@ fn listing(dir: &Path) -> Vec<(PathBuf, u64)> {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660),
|
||||
/// with a link, a URI link and a comment on every page, and a form field and text added by
|
||||
/// PdfCraft (its `/PCAdded` marker) on the last. Readers
|
||||
/// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while
|
||||
/// the editing layer sees all six, so annotations come back on pages the viewer does not have.
|
||||
fn dangling_kid_pdf() -> Vec<u8> {
|
||||
let pages: Vec<u32> = (0..6).map(|i| 10 + 2 * i).collect();
|
||||
let mut kids: Vec<u32> = pages[..3].to_vec();
|
||||
kids.push(77); // does not exist
|
||||
kids.extend(&pages[3..]);
|
||||
let kids: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
|
||||
let mut objs: Vec<(u32, String)> = vec![
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [50 0 R] >> >>".into()),
|
||||
(2, format!("<< /Type /Pages /Kids [{}] /Count 6 >>", kids.join(" "))),
|
||||
(90, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()),
|
||||
];
|
||||
for (i, &page) in pages.iter().enumerate() {
|
||||
let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1);
|
||||
let (field, contents) = if i == 5 { (" 50 0 R", format!("[{} 0 R 60 0 R]", page + 1)) } else { ("", format!("{} 0 R", page + 1)) };
|
||||
objs.push((
|
||||
page,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {contents} \
|
||||
/Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \
|
||||
<< /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \
|
||||
<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>"
|
||||
),
|
||||
));
|
||||
objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len())));
|
||||
}
|
||||
objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into()));
|
||||
let added = "BT /F1 12 Tf 72 80 Td (AddedSample) Tj ET";
|
||||
objs.push((
|
||||
60,
|
||||
format!(
|
||||
"<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream",
|
||||
added.len()
|
||||
),
|
||||
));
|
||||
objs.sort_by_key(|(n, _)| *n);
|
||||
|
||||
let size = 100;
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = vec![None; size];
|
||||
for (num, body) in &objs {
|
||||
if let Some(slot) = offsets.get_mut(*num as usize) {
|
||||
*slot = Some(out.len());
|
||||
}
|
||||
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
|
||||
for offset in offsets.iter().skip(1) {
|
||||
out.extend_from_slice(
|
||||
match offset {
|
||||
Some(o) => format!("{o:010} 00000 n \n"),
|
||||
None => "0000000000 65535 f \n".to_string(),
|
||||
}
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() {
|
||||
// #2660: `link_list` indexed the viewer's pages with a page number from the editing layer
|
||||
// ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a
|
||||
// file where the two disagree, must answer with a result or an error.
|
||||
let root = tmp("dangling-kid");
|
||||
// The JSON a call answers with, or the error it gave.
|
||||
let answer = |a: &mut Automation, tool: &str, args: &Value| -> std::result::Result<Value, String> {
|
||||
match catch_unwind(AssertUnwindSafe(|| a.call(tool, args))) {
|
||||
Ok(Ok(content)) => content
|
||||
.into_iter()
|
||||
.find_map(|c| match c {
|
||||
pdfcraft_automation::Content::Json(v) => Some(v),
|
||||
pdfcraft_automation::Content::Png { .. } => None,
|
||||
})
|
||||
.ok_or_else(|| format!("{tool} answered without JSON")),
|
||||
Ok(Err(e)) => Err(format!("{tool} failed: {e:?}")),
|
||||
Err(_) => Err(format!("{tool} panicked")),
|
||||
}
|
||||
};
|
||||
let pdf = dangling_kid_pdf();
|
||||
let mut failures = Vec::new();
|
||||
for def in tools() {
|
||||
// Written again for every tool: tools that save write to `in.pdf`, the plausible path.
|
||||
std::fs::write(root.join("in.pdf"), &pdf).expect("fixture");
|
||||
let mut a = Automation::new().with_root(&root).expect("root");
|
||||
let opened = a.call("doc_open", &json!({ "path": "in.pdf" })).expect("open the damaged file");
|
||||
let doc = opened
|
||||
.into_iter()
|
||||
.find_map(|c| match c {
|
||||
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
|
||||
pdfcraft_automation::Content::Png { .. } => None,
|
||||
})
|
||||
.expect("the opened document has an id");
|
||||
if let Err(e) = guarded(&mut a, def.name, &Value::Object(plausible_with(&def, Some(doc)))) {
|
||||
failures.push(e);
|
||||
}
|
||||
match def.name {
|
||||
// The field sits on the sixth page; a rect is placed on the field's own page. That is
|
||||
// refused, saying why, and the field is left as it was.
|
||||
"form_set_props" => {
|
||||
let fields = |a: &mut Automation| answer(a, "form_fields", &json!({ "doc": doc }));
|
||||
let before = fields(&mut a);
|
||||
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })))) {
|
||||
Ok(Err(ToolError::Failed(m))) if m == "name is on page 6, which could not be read" => {}
|
||||
Ok(Err(e)) => failures.push(format!("form_set_props gave the wrong error: {e:?}")),
|
||||
Ok(Ok(_)) => failures.push("form_set_props placed a rect on a page the viewer could not read".into()),
|
||||
Err(_) => failures.push("form_set_props panicked on a rect for the field on page 6".into()),
|
||||
}
|
||||
let after = fields(&mut a);
|
||||
if before.is_err() || before != after {
|
||||
failures.push(format!("form_set_props changed the field: {before:?} became {after:?}"));
|
||||
}
|
||||
}
|
||||
// Listing still reports what is on the pages the viewer lacks, not just survives it.
|
||||
"link_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) {
|
||||
Ok(v) if v["count"] == 12 => {}
|
||||
other => failures.push(format!("link_list did not list the file's 12 links: {other:?}")),
|
||||
},
|
||||
// The added text on page 6 is reported, its rect unchanged (user space), as
|
||||
// `doc_info` reports rects on pages the viewer could not read.
|
||||
"content_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) {
|
||||
Ok(v)
|
||||
if v["items"].as_array().is_some_and(|items| {
|
||||
items.iter().any(|it| it["page"] == 6 && it["type"] == "text" && it["rect"] == json!([70.0, 70.0, 150.0, 100.0]))
|
||||
}) => {}
|
||||
other => failures.push(format!("content_list did not report the added text on page 6 at [70, 70, 150, 100]: {other:?}")),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for f in &failures {
|
||||
eprintln!(" {f}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert!(failures.is_empty(), "{} failure(s); the first is: {}", failures.len(), failures.first().map_or("", String::as_str));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod automation;
|
||||
mod automation_roots;
|
||||
mod batch;
|
||||
mod button_caption;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod optimize_content_boundaries;
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Contributor-original PDFs exercise optimization across content stream boundaries.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const COLORS: [[u8; 3]; 4] = [[220, 30, 40], [20, 190, 60], [30, 60, 220], [230, 190, 20]];
|
||||
|
||||
fn fixture(ending: &str, split_operands: bool) -> Vec<u8> {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let font = doc.add(Object::Dict(Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Font")),
|
||||
(b"Subtype".to_vec(), Object::name("Type1")),
|
||||
(b"BaseFont".to_vec(), Object::name("Helvetica")),
|
||||
])));
|
||||
let mut xobjects = Dict::new();
|
||||
for (i, color) in COLORS.iter().enumerate() {
|
||||
let dict = Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("XObject")),
|
||||
(b"Subtype".to_vec(), Object::name("Image")),
|
||||
(b"Width".to_vec(), 20.into()),
|
||||
(b"Height".to_vec(), 5.into()),
|
||||
(b"BitsPerComponent".to_vec(), 8.into()),
|
||||
(b"ColorSpace".to_vec(), Object::name("DeviceRGB")),
|
||||
]);
|
||||
let image = doc.add(Object::Stream(Stream::flate(dict, &color.repeat(100))));
|
||||
xobjects.set(format!("Im{i}").into_bytes(), Object::Ref(image));
|
||||
}
|
||||
// q and the translation stay active across both pieces. The optional split places
|
||||
// the final two cm operands and its operator in the next stream.
|
||||
let first = if split_operands {
|
||||
"q 1 0 0 1".to_string()
|
||||
} else {
|
||||
format!("q 1 0 0 1 10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n% trailing comment{ending}")
|
||||
};
|
||||
let mut second = if split_operands {
|
||||
"10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n".to_string()
|
||||
} else {
|
||||
"BT /F1 20 Tf 30 400 Td (HIDDEN) Tj ET\n".to_string()
|
||||
};
|
||||
for i in 0..4 {
|
||||
second.push_str(&format!("q 20 0 0 5 100 {} cm /Im{i} Do Q\n", 200 + (3 - i) * 5));
|
||||
}
|
||||
second.push_str("BT /F1 20 Tf 30 100 Td (LAST) Tj ET\nQ\n");
|
||||
let contents: Vec<Object> =
|
||||
[first, second].into_iter().map(|piece| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), piece.into_bytes()))))).collect();
|
||||
let resources = Dict::from_iter([
|
||||
(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font))]))),
|
||||
(b"XObject".to_vec(), Object::Dict(xobjects)),
|
||||
]);
|
||||
let page = doc.add(Object::Dict(Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Page")),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])),
|
||||
(b"Resources".to_vec(), Object::Dict(resources)),
|
||||
(b"Contents".to_vec(), Object::Array(contents)),
|
||||
])));
|
||||
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::default()).unwrap()
|
||||
}
|
||||
|
||||
fn call(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
let result = a.call(tool, &args).unwrap();
|
||||
let Content::Json(value) = &result[0] else { panic!("expected JSON from {tool}") };
|
||||
value.clone()
|
||||
}
|
||||
|
||||
fn check_page(a: &mut Automation, doc: u64, hidden: bool, phase: &str) {
|
||||
let text = call(a, "text_extract", json!({"doc": doc}));
|
||||
let words: Vec<_> = text["pages"][0]["text"].as_str().unwrap().split_whitespace().collect();
|
||||
assert_eq!(words, if hidden { vec!["FIRST", "HIDDEN", "LAST"] } else { vec!["FIRST", "LAST"] }, "{phase} text");
|
||||
let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
|
||||
let Content::Png { data, width, height } = &rendered[0] else { panic!("expected page PNG") };
|
||||
assert_eq!((*width, *height), (600, 800));
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut pixels).unwrap();
|
||||
let channels = info.color_type.samples();
|
||||
assert!(channels >= 3);
|
||||
let pixel = |x: usize, y: usize| &pixels[(y * 600 + x) * channels..(y * 600 + x) * channels + 3];
|
||||
let dark = |left, top, right, bottom| {
|
||||
(top..bottom).flat_map(|y| (left..right).map(move |x| (x, y))).filter(|&(x, y)| pixel(x, y).iter().any(|v| *v < 128)).count()
|
||||
};
|
||||
assert!(dark(35, 45, 130, 75) > 30, "FIRST must draw at its translated position");
|
||||
assert!(dark(35, 675, 130, 705) > 30, "LAST must draw at its translated position");
|
||||
assert_eq!(dark(35, 375, 150, 405) > 0, hidden, "HIDDEN region must match the real comment ending");
|
||||
for (row, color) in COLORS.iter().enumerate() {
|
||||
for y in 581 + row * 5..584 + row * 5 {
|
||||
for x in 112..128 {
|
||||
assert_eq!(pixel(x, y), color, "strip {row}, pixel {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(pixel(105, 590), [255, 255, 255], "the cross-piece translation stays active");
|
||||
}
|
||||
|
||||
fn check_saved_images(bytes: Vec<u8>, merged: bool) {
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap());
|
||||
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
|
||||
let page = doc.resolve(&kids[0]);
|
||||
let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());
|
||||
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
let images: Vec<_> = xobjects.as_dict().unwrap().iter().map(|(_, value)| doc.resolve(value)).collect();
|
||||
assert_eq!(images.len(), if merged { 1 } else { 4 });
|
||||
if merged {
|
||||
let Object::Stream(image) = &*images[0] else { panic!("expected saved image") };
|
||||
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(20), Some(20)));
|
||||
let expected: Vec<_> = COLORS.iter().flat_map(|color| color.repeat(100)).collect();
|
||||
assert_eq!(image.decoded().unwrap(), expected, "saved RGB rows keep their strip order");
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(ending: &str, split_operands: bool, merge: bool) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("input.pdf"), fixture(ending, split_operands)).unwrap();
|
||||
let mut a = Automation::new().with_root(dir.path()).unwrap();
|
||||
let doc = call(&mut a, "doc_open", json!({"path": "input.pdf"}))["doc"].as_u64().unwrap();
|
||||
let hidden = !ending.is_empty() && !split_operands;
|
||||
check_page(&mut a, doc, hidden, "original");
|
||||
let report = call(
|
||||
&mut a,
|
||||
"doc_optimize",
|
||||
json!({
|
||||
"doc": doc, "path": "output.pdf", "merge_fragments": merge,
|
||||
"color": {"downsample": false, "compression": "retain"},
|
||||
"gray": {"downsample": false, "compression": "retain"}
|
||||
}),
|
||||
);
|
||||
assert_eq!(report["fragments_merged"], if merge { 4 } else { 0 });
|
||||
assert_eq!(report["merged_images"], if merge { 1 } else { 0 });
|
||||
let reopened = call(&mut a, "doc_open", json!({"path": "output.pdf"}))["doc"].as_u64().unwrap();
|
||||
check_page(&mut a, reopened, hidden, "reopened");
|
||||
check_saved_images(std::fs::read(dir.path().join("output.pdf")).unwrap(), merge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_merge_preserves_a_comment_across_content_pieces() {
|
||||
roundtrip("", false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_merge_preserves_explicit_comment_line_endings() {
|
||||
for ending in ["\n", "\r", "\r\n"] {
|
||||
roundtrip(ending, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_merge_preserves_split_operands_and_graphics_state() {
|
||||
roundtrip("", true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_fragment_merge_preserves_content_pieces() {
|
||||
roundtrip("", false, false);
|
||||
}
|
||||
+132
-12
@@ -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> {
|
||||
@@ -169,19 +219,40 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize
|
||||
return Ok((dict, Vec::new(), data.len()));
|
||||
}
|
||||
// Without an `EI` after it, the image runs to the end of the stream.
|
||||
return Err(end_of_image(data, at).map_or(data.len(), |(_, end)| end));
|
||||
return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end));
|
||||
};
|
||||
dict.set(k, v);
|
||||
}
|
||||
// One whitespace byte separates ID from the data.
|
||||
let begin = (lx.pos + 1).min(data.len());
|
||||
let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin));
|
||||
let end = unfiltered_image_end(data, begin, &dict)
|
||||
.or_else(|| declared_image_end(data, begin, &dict))
|
||||
.or_else(|| image_data_end(data, begin, filter_eod(&dict)));
|
||||
Ok(match end {
|
||||
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
|
||||
None => (dict, data[begin..].to_vec(), data.len()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any.
|
||||
fn filter_eod(dict: &Dict) -> &'static [u8] {
|
||||
for key in [b"Filter".as_slice(), b"F"] {
|
||||
if let Some(filter) = dict.get(key) {
|
||||
let name = match filter {
|
||||
Object::Name(name) => Some(name.as_slice()),
|
||||
Object::Array(filters) => filters.first().and_then(Object::as_name),
|
||||
_ => None,
|
||||
};
|
||||
return match name {
|
||||
Some(b"ASCIIHexDecode" | b"AHx") => b">",
|
||||
Some(b"ASCII85Decode" | b"A85") => b"~>",
|
||||
_ => b"",
|
||||
};
|
||||
}
|
||||
}
|
||||
b""
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -243,23 +314,60 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize
|
||||
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)) {
|
||||
let separator = after.iter().position(|b| !is_ws(*b))?;
|
||||
if separator == 0 && data.get(end_data.checked_sub(1)?).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 }
|
||||
}
|
||||
|
||||
/// The length declared by `/L` (or `/Length`, ISO 32000-2 §8.9.7), used when `EI` follows the
|
||||
/// declared bytes after optional whitespace. A declared length without that `EI` is ignored.
|
||||
fn declared_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> {
|
||||
let length = dict.get(b"L").or_else(|| dict.get(b"Length"))?.as_int()?;
|
||||
let end_data = begin.checked_add(usize::try_from(length).ok()?)?;
|
||||
let after = data.get(end_data..)?;
|
||||
let ei = end_data.checked_add(after.iter().position(|b| !is_ws(*b))?)?;
|
||||
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 of unknown length ends. A candidate `EI` followed by binary bytes before
|
||||
/// the next `EI` lies inside the (compressed) data, unless another inline image starts first.
|
||||
fn image_data_end(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> {
|
||||
let mut candidate = end_of_image(data, begin, eod)?;
|
||||
loop {
|
||||
let (end_data, end) = candidate;
|
||||
let Some(next) = end_of_image(data, end, eod) else {
|
||||
return Some(candidate);
|
||||
};
|
||||
let between = data.get(end..next.0).unwrap_or_default();
|
||||
if !between.iter().any(|b| !b.is_ascii()) || starts_inline_image(between) {
|
||||
return Some((end_data, end));
|
||||
}
|
||||
candidate = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` hold a whitespace-delimited `BI` operator.
|
||||
fn starts_inline_image(bytes: &[u8]) -> bool {
|
||||
bytes
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.any(|(i, pair)| pair == b"BI" && (i == 0 || is_ws(bytes[i - 1])) && bytes.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b)))
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
/// data ends at whitespace (or `eod`: `>` for ASCIIHex, `~>` for ASCII85) + `EI` + (whitespace,
|
||||
/// a delimiter or the end).
|
||||
fn end_of_image(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> {
|
||||
let mut i = begin;
|
||||
while i + 2 <= data.len() {
|
||||
if data[i] == b'E'
|
||||
&& data.get(i + 1) == Some(&b'I')
|
||||
&& (i == begin || is_ws(data[i - 1]))
|
||||
&& (i == begin || is_ws(data[i - 1]) || (!eod.is_empty() && data[begin..i].ends_with(eod)))
|
||||
&& data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b))
|
||||
{
|
||||
let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i };
|
||||
@@ -305,7 +413,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>,
|
||||
@@ -319,7 +429,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 }
|
||||
@@ -341,12 +451,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).
|
||||
|
||||
@@ -151,6 +151,32 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41,
|
||||
/// the way a stored deflate block copies incompressible samples verbatim (#3579).
|
||||
const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\
|
||||
31206c2025630aaa4353e6813e233b96539c18bc3a2c5009e9d51b81";
|
||||
|
||||
#[test]
|
||||
fn flate_inline_image_is_not_cut_at_an_ei_inside_its_compressed_bytes() {
|
||||
let flate: Vec<u8> = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect();
|
||||
assert_eq!(flate.len(), 75);
|
||||
assert_eq!(&flate[41..45], b" EI\n");
|
||||
for parameters in [b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl".as_slice(), b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl /L 75"] {
|
||||
let mut src = b"q 200 0 0 200 100 300 cm BI ".to_vec();
|
||||
src.extend_from_slice(parameters);
|
||||
src.extend_from_slice(b" ID ");
|
||||
src.extend_from_slice(&flate);
|
||||
src.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET");
|
||||
let parsed = parse(&src);
|
||||
let label = String::from_utf8_lossy(parameters);
|
||||
assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1.len(), 75, "{label}");
|
||||
let names: Vec<&[u8]> = parsed.ops.iter().map(|o| o.op.as_slice()).collect();
|
||||
assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"BT", b"Tf", b"Td", b"Tj", b"ET"], "{label}");
|
||||
assert_eq!(parsed.skipped, 0, "{label}");
|
||||
assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[2].inline, parsed.ops[2].inline, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_pdf(content: &[u8], object_streams: bool) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};
|
||||
|
||||
@@ -283,3 +309,153 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_terminator_without_preceding_whitespace() {
|
||||
let cases: &[(&[u8], &[u8])] = &[
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F /AHx ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCIIHexDecode ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx /RL] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCII85Decode ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /RL] ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab()EI Q BT /F1 18 Tf (After) Tj ET", b"ab()"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab[]EI Q BT /F1 18 Tf (After) Tj ET", b"ab[]"),
|
||||
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0EI Q BT /F1 18 Tf (After) Tj ET", b"\x20\x60\xa0\xe0"),
|
||||
];
|
||||
|
||||
for (src, expected_data) in cases {
|
||||
let p = parse(src);
|
||||
assert_eq!(p.skipped, 0, "stream should have 0 skipped bytes: {}", String::from_utf8_lossy(src));
|
||||
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"],
|
||||
"operators after inline image must be preserved: {}",
|
||||
String::from_utf8_lossy(src)
|
||||
);
|
||||
let (_, img) = p.ops[0].inline.as_ref().unwrap();
|
||||
assert_eq!(img, expected_data, "inline image data must match: {}", String::from_utf8_lossy(src));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() {
|
||||
for src in [
|
||||
b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(),
|
||||
b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /AHx] ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(),
|
||||
] {
|
||||
let p = parse(src);
|
||||
assert_eq!(p.skipped, 0);
|
||||
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
|
||||
assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]);
|
||||
let (_, img) = p.ops[0].inline.as_ref().unwrap();
|
||||
assert_eq!(img, b"!!!!>EI !!~>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
pdfcraft-crypt = { workspace = true }
|
||||
pdfcraft-filters = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code).
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-oracle = { workspace = true }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user