mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
Merge main into feat/discard-external-refs
main split crates/automation/src/lib.rs into modules; the doc_optimize option and its two counts now go in documents.rs.
This commit is contained in:
commit
462ddffbe7
589 files changed
+119883
-96804
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -32,6 +32,9 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Windows font notice regressions
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-font-notices.ps1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -73,13 +76,13 @@ jobs:
|
||||
# the org's 5 macOS slots are kept for release builds, and release.yml builds macOS.
|
||||
os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }}
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml.
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
# egui/winit/rfd, and the independent oracles (crates/oracle).
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev qpdf poppler-utils
|
||||
# cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI.
|
||||
tools: cargo-nextest@0.9.149,cargo-deny@0.20.2
|
||||
tools: cargo-nextest@0.9.150,cargo-deny@0.20.2
|
||||
# A missing oracle fails the job instead of printing a skip notice that reads as a pass.
|
||||
linux-env: PDFCRAFT_ORACLES_REQUIRED=1
|
||||
windows-env: PDFCRAFT_REQUIRE_WINRES=1
|
||||
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
# 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/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: pdfcraft
|
||||
app-title: PdfCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
+44
-42
@@ -4,37 +4,38 @@
|
||||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (288)
|
||||
## In this repository (289)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) 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-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
@@ -285,26 +286,27 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
|
||||
## Compiled in through dependencies (5)
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
| Crate | File | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `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) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `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.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
|
||||
| `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)
|
||||
@@ -333,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
|
||||
| `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) |
|
||||
| `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
|
||||
| `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
|
||||
| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
|
||||
| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
|
||||
| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
|
||||
| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
|
||||
|
||||
## Optional build inputs (1)
|
||||
|
||||
@@ -342,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
|
||||
|
||||
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size |
|
||||
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
|
||||
+106
-84
@@ -2,11 +2,11 @@
|
||||
path = "crates/ui-egui/src/i18n/el.tsv"
|
||||
title = "Greek interface translation catalog"
|
||||
author = "Camthalion77 and contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
# 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`).
|
||||
@@ -26,260 +26,270 @@ usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
|
||||
title = "Simplified Chinese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
title = "Traditional Chinese (Taiwan) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
title = "Czech interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
title = "Brazilian Portuguese interface translation catalog"
|
||||
author = "André Cardoso (andrecards), PdfCraft contributors"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
title = "Spanish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
title = "French interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
title = "German interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lo.tsv"
|
||||
title = "Lao interface translation catalog"
|
||||
author = "@bounkirdni-2025"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
title = "Telugu interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
title = "Italian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
title = "Polish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
title = "Russian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
title = "Hungarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
title = "Bulgarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
title = "Kazakh interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
title = "Japanese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
title = "Ukrainian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
title = "Arabic interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ckb.tsv"
|
||||
title = "Central Kurdish (Sorani) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fa.tsv"
|
||||
title = "Persian (Farsi) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
title = "Turkish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
title = "Lithuanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hi.tsv"
|
||||
title = "Hindi interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/id.tsv"
|
||||
title = "Indonesian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ko.tsv"
|
||||
title = "Korean interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ro.tsv"
|
||||
title = "Romanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fi.tsv"
|
||||
title = "Finnish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -2429,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/Ubuntu-Light.ttf"
|
||||
title = "Ubuntu Light"
|
||||
author = "Canonical Ltd. / Dalton Maag"
|
||||
@@ -2441,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/Hack-Regular.ttf"
|
||||
title = "Hack Regular"
|
||||
author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)"
|
||||
@@ -2453,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/NotoEmoji-Regular.ttf"
|
||||
title = "Noto Emoji (monochrome)"
|
||||
author = "Google Inc."
|
||||
source = "https://github.com/googlefonts/noto-emoji"
|
||||
licence = "OFL-1.1"
|
||||
kind = "font"
|
||||
usage = "egui fallback fonts (eframe feature default_fonts)"
|
||||
usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
|
||||
sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/emoji-icon-font.ttf"
|
||||
title = "emoji-icon-font"
|
||||
author = "John Slegers"
|
||||
source = "https://github.com/jslegers/emoji-icon-font"
|
||||
licence = "MIT"
|
||||
kind = "font"
|
||||
usage = "egui fallback fonts (eframe feature default_fonts)"
|
||||
usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
|
||||
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.37.0"
|
||||
path = "fonts/egui-icons.ttf"
|
||||
title = "egui icons (monochrome emoji-icon-font subset)"
|
||||
author = "John Slegers"
|
||||
source = "https://github.com/jslegers/emoji-icon-font"
|
||||
licence = "MIT"
|
||||
kind = "font"
|
||||
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
|
||||
sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
|
||||
|
||||
[[asset]]
|
||||
path = "vendor/hayro-cmap/assets/cmaps.brotli"
|
||||
title = "Adobe CMap resources (predefined CJK CMaps)"
|
||||
@@ -3334,41 +3356,41 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa
|
||||
sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
|
||||
|
||||
[[fetched]]
|
||||
file = "text-detection.rten"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"
|
||||
file = "text-detection.onnx"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx"
|
||||
title = "Ocrs text detection model"
|
||||
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
|
||||
licence = "CC-BY-SA-4.0"
|
||||
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
|
||||
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
kind = "model"
|
||||
usage = "Scan & OCR: finds words on page images (cargo xtask models)"
|
||||
sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"
|
||||
sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"
|
||||
|
||||
[[fetched]]
|
||||
file = "text-recognition.rten"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"
|
||||
file = "text-recognition.onnx"
|
||||
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx"
|
||||
title = "Ocrs text recognition model"
|
||||
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
|
||||
source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
|
||||
licence = "CC-BY-SA-4.0"
|
||||
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
|
||||
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
kind = "model"
|
||||
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
|
||||
sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
|
||||
sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
|
||||
|
||||
[[build_input]]
|
||||
name = "craft-fonts"
|
||||
option = "CRAFT_FONTS_DIR"
|
||||
title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic"
|
||||
author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)"
|
||||
source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)"
|
||||
title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic"
|
||||
author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)"
|
||||
source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)"
|
||||
licence = "OFL-1.1"
|
||||
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
|
||||
kind = "font"
|
||||
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size"
|
||||
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
|
||||
@@ -3396,21 +3418,21 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
|
||||
path = "crates/ui-egui/src/i18n/vi.tsv"
|
||||
title = "Vietnamese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
|
||||
@@ -3428,11 +3450,11 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
path = "crates/ui-egui/src/i18n/nb.tsv"
|
||||
title = "Norwegian Bokmål interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/superscript.svg"
|
||||
@@ -3482,28 +3504,28 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
|
||||
path = "crates/ui-egui/src/i18n/sq.tsv"
|
||||
title = "Albanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/th.tsv"
|
||||
title = "Thai interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ca.tsv"
|
||||
title = "Catalan interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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"
|
||||
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
@@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- See `plan/README.md` §Clean-room and `plan/adr/0001`.
|
||||
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
|
||||
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
|
||||
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
|
||||
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
|
||||
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
|
||||
|
||||
Generated
+686
-352
File diff suppressed because it is too large.
Load diff
+68
-44
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,47 +13,51 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.13.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.13.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.13.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.13.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.13.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.13.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.13.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.14.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.14.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.14.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.14.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.14.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.14.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.14.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -75,11 +79,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass).
|
||||
web-time = "1.1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -118,6 +124,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" }
|
||||
hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
|
||||
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
|
||||
# earlier patches and their source guards, not path patches; the other `vendor/` entries above
|
||||
# remain real path patches. See vendor/README.md and the migration plan.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
@@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006).
|
||||
Vendored (vendor/, patched; see vendor/README.md):
|
||||
- hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro).
|
||||
- hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret).
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
|
||||
emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
|
||||
included through monochrome_emoji_fonts. Their upstream project is
|
||||
https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
|
||||
and permission notice below.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json quit
|
||||
```
|
||||
|
||||
`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view.
|
||||
Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`,
|
||||
`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and
|
||||
`zone: center|left|right|top|bottom`. For example,
|
||||
`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools.
|
||||
`ui.dock.reset` restores the default
|
||||
arrangement. The control channel accepts these methods directly; `ui.command` also accepts
|
||||
`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility
|
||||
and floating geometry. Invalid requests return an error without changing the document or layout.
|
||||
`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`.
|
||||
`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last).
|
||||
`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset`
|
||||
resets the side panels: Tools open on the left, default widths. The control channel accepts these
|
||||
methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep
|
||||
the Tools side, the open panels and their widths. Invalid requests return an error without changing
|
||||
the document or the panels.
|
||||
|
||||
|
||||
`resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only.
|
||||
@@ -595,13 +593,4 @@ Forks and modified versions must remove them.
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
|
||||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||||
rules apply equally to both forms.
|
||||
The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement.
|
||||
+15
-4
@@ -2,7 +2,8 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
@@ -165,10 +166,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
|
||||
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 100% | partial (layout not mirrored) |
|
||||
| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
|
||||
| French | `fr` | 99.3% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
|
||||
| Indonesian | `id` | 0% | none |
|
||||
@@ -188,7 +189,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| Rank | Milestone | Estimate (h) |
|
||||
|---|---|---|
|
||||
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
|
||||
| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
|
||||
| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 |
|
||||
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
|
||||
| 3 | Editing existing content (M7) | 100–180 |
|
||||
| 4 | OCR languages and Office export/import (M10) | 95–175 |
|
||||
@@ -199,6 +200,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
|
||||
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
|
||||
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
|
||||
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
|
||||
@@ -223,6 +229,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **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 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. 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.
|
||||
@@ -316,6 +323,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **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-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. 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.
|
||||
@@ -670,10 +678,13 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
|
||||
|
||||
**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-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
|
||||
@@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-preflight = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
|
||||
[dev-dependencies]
|
||||
# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::commands::{parse_dpi, read};
|
||||
use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options};
|
||||
use super::*;
|
||||
|
||||
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
|
||||
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
|
||||
pub(crate) fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("audit", args, AUDIT_OPTIONS)?;
|
||||
let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
|
||||
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
|
||||
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
|
||||
// reports them; the page geometry is read only when there is something to place.
|
||||
let info = if findings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
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| {
|
||||
let rect = f.rect.map(|v| v as f32);
|
||||
serde_json::json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
|
||||
if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
|
||||
}
|
||||
|
||||
/// Child-process body for `check`: prints one JSON line.
|
||||
pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
|
||||
let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
|
||||
let start = Instant::now();
|
||||
let bytes = read(path)?;
|
||||
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
|
||||
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
|
||||
Ok(info) => {
|
||||
let mut r = PageRenderer::new(bytes, RenderConfig::default());
|
||||
let mut failed = Vec::new();
|
||||
for i in 0..info.pages.len().min(500) {
|
||||
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
|
||||
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
|
||||
failed.push(format!("p{} text: {e}", i + 1));
|
||||
}
|
||||
if let Some(e) = p.error {
|
||||
failed.push(format!("p{}: {e}", i + 1));
|
||||
}
|
||||
}
|
||||
let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
|
||||
(status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
|
||||
}
|
||||
};
|
||||
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
|
||||
// so a panic in cos shows up as a crash of this process.
|
||||
if args.iter().any(|a| a == "--edit") {
|
||||
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
|
||||
}
|
||||
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
|
||||
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
|
||||
stdout_line(format_args!("{line}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
|
||||
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
|
||||
fn edit_round_trip(bytes: &[u8]) {
|
||||
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
|
||||
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
|
||||
for num in doc.object_numbers().into_iter().take(20_000) {
|
||||
if let Ok(o) = doc.try_get(num)
|
||||
&& let Object::Stream(s) = &*o
|
||||
{
|
||||
let _ = s.decoded();
|
||||
}
|
||||
}
|
||||
if let Some(root) = doc.root() {
|
||||
let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true)));
|
||||
}
|
||||
let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
|
||||
for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Ok(again) = Document::open(Arc::new(out)) {
|
||||
let _ = again.root().map(|r| again.get(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::<Vec<_>>().join("-").to_lowercase()
|
||||
}
|
||||
|
||||
fn collect(paths: &[&str]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(p) = stack.pop() {
|
||||
if p.is_dir() {
|
||||
// Directory aliases and symlink cycles must not scan the same tree repeatedly.
|
||||
let identity = match p.canonicalize() {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
eprintln!("{}: could not resolve directory: {error}", p.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !visited.insert(identity) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||
stack.extend(rd.flatten().map(|e| e.path()));
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) 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, CHECK_OPTIONS));
|
||||
if files.is_empty() {
|
||||
return Err("check: no PDF files found".into());
|
||||
}
|
||||
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();
|
||||
for (n, f) in files.iter().enumerate() {
|
||||
let r = run_child(&exe, f, &dpi, timeout);
|
||||
let status = r["status"].as_str().unwrap_or("?").to_string();
|
||||
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
|
||||
if status != "ok" {
|
||||
eprintln!(
|
||||
"[{}/{}] {status:<28} {} {}",
|
||||
n + 1,
|
||||
files.len(),
|
||||
f.display(),
|
||||
r["detail"].as_str().unwrap_or("").chars().take(160).collect::<String>()
|
||||
);
|
||||
}
|
||||
results.push(r);
|
||||
}
|
||||
eprintln!("\nchecked {} files: {:?}", files.len(), counts);
|
||||
if let Some(out) = flag(args, "--json") {
|
||||
std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
|
||||
let file_s = file.to_string_lossy().to_string();
|
||||
let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
|
||||
};
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut out = String::new();
|
||||
if let Some(mut so) = child.stdout.take() {
|
||||
use std::io::Read;
|
||||
let _ = so.read_to_string(&mut out);
|
||||
}
|
||||
return match serde_json::from_str::<serde_json::Value>(out.trim()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
|
||||
};
|
||||
}
|
||||
Ok(None) if start.elapsed() > timeout => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
|
||||
}
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
|
||||
Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
use super::options::{
|
||||
COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional,
|
||||
validate_options,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
||||
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("info", args, INFO_OPTIONS)?;
|
||||
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
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| {
|
||||
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,
|
||||
});
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn text(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("text", args, TEXT_OPTIONS)?;
|
||||
let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?;
|
||||
let mut selected_page = None;
|
||||
// Validate every supplied value before reading, retaining the first valid selection.
|
||||
let mut options = args.iter();
|
||||
while let Some(option) = options.next() {
|
||||
if option == "--page" {
|
||||
let page = options
|
||||
.next()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|page| *page > 0)
|
||||
.ok_or("bad --page: expected a positive page number")?;
|
||||
selected_page.get_or_insert(page - 1);
|
||||
} else if option.starts_with("--") {
|
||||
// Match positional(): an option's operand is not itself another option.
|
||||
options.next();
|
||||
}
|
||||
}
|
||||
let password = flag(args, "--password");
|
||||
let mut bytes = read(path)?;
|
||||
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
|
||||
// Worked out from the file as read: `bytes` may become the session's display copy below.
|
||||
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
|
||||
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
|
||||
// permissions password, a document that doesn't allow copying gives no text.
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
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],
|
||||
None => (0..r.page_count()).collect(),
|
||||
};
|
||||
let mut failed: Vec<usize> = Vec::new();
|
||||
for (n, p) in pages.iter().enumerate() {
|
||||
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
if let Some(e) = out.error {
|
||||
let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
|
||||
failed.push(p + 1);
|
||||
continue;
|
||||
}
|
||||
if n > 0 {
|
||||
stdout_line(format_args!("\u{c}"))?;
|
||||
}
|
||||
stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?;
|
||||
}
|
||||
if failed.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
|
||||
const MAX_RANGE: usize = 1_000_000;
|
||||
|
||||
/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
|
||||
/// indices (#1923).
|
||||
fn page_list(s: &str) -> Result<Vec<usize>, String> {
|
||||
let number = |t: &str, item: &str| t.trim().parse::<usize>().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
|
||||
let mut out = Vec::new();
|
||||
for item in s.split(',').map(str::trim) {
|
||||
match item.split_once('-') {
|
||||
Some((from, to)) => {
|
||||
let (from, to) = (number(from, item)?, number(to, item)?);
|
||||
if to < from {
|
||||
return Err(format!("bad page range {item:?}: it ends before it starts"));
|
||||
}
|
||||
if to - from >= MAX_RANGE {
|
||||
return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
|
||||
}
|
||||
out.extend(from - 1..to);
|
||||
}
|
||||
None => out.push(number(item, item)? - 1),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The options `edit` understands, for its "unknown option" message.
|
||||
const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author";
|
||||
|
||||
/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
|
||||
///
|
||||
/// Every argument is parsed before the file is opened: an option this command does not know
|
||||
/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an
|
||||
/// unchanged copy and exit 0, which looks like a successful edit (#133).
|
||||
pub(crate) fn edit(args: &[String]) -> Result<(), CliError> {
|
||||
use pdfcraft_engine::{Edit, Session};
|
||||
let mut path: Option<&str> = None;
|
||||
let mut out: Option<&str> = None;
|
||||
let mut password: Option<&str> = None;
|
||||
let mut full = false;
|
||||
let mut edits = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i).map(String::as_str) {
|
||||
// Options that take no value, and the input file.
|
||||
match arg {
|
||||
"--full" => {
|
||||
full = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
a if a.starts_with("--") => {}
|
||||
a => {
|
||||
if path.is_some() {
|
||||
return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into());
|
||||
}
|
||||
path = Some(a);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// `edit` parses its own options (they run in order), so `--out` is checked here in the
|
||||
// same way: it wrote a PDF to a file called `--full`.
|
||||
let value = args.get(i + 1).map(String::as_str);
|
||||
if !can_be_path_value(arg, value) {
|
||||
return Err(needs_value("edit", arg, value).into());
|
||||
}
|
||||
let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?;
|
||||
match arg {
|
||||
"--out" => out = Some(value),
|
||||
"--password" => password = Some(value),
|
||||
"--rotate" => {
|
||||
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
|
||||
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 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 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() }),
|
||||
other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
let path = path.ok_or("edit: missing file")?;
|
||||
let out = out.ok_or("edit: missing --out")?;
|
||||
let mut session = Session::new();
|
||||
let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
|
||||
for e in edits {
|
||||
// A destination past the end must fail like `page_move` / `page_insert_blank` do, not
|
||||
// silently become an append (#1330). Checked per edit: earlier edits change the count.
|
||||
let n = session.get(id).map_or(0, |d| d.info.pages.len());
|
||||
let destination = match &e {
|
||||
Edit::MovePages { to, .. } => Some(("--move target", *to)),
|
||||
Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
|
||||
return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
|
||||
}
|
||||
// Pages count from the document as the earlier options left it, so a page past the end is
|
||||
// reported with its own option and the current page count (#1550).
|
||||
let listed = match &e {
|
||||
Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
|
||||
Edit::DeletePages { pages } => Some(("--delete", pages)),
|
||||
Edit::MovePages { pages, .. } => Some(("--move", pages)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
|
||||
return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
|
||||
}
|
||||
session.apply(id, e).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
|
||||
// As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
|
||||
session.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
|
||||
/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
|
||||
fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
|
||||
use pdfcraft_platform::staging::{StagingName, write_atomically};
|
||||
write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("combine", args, COMBINE_OPTIONS)?;
|
||||
let out = flag(args, "--out").ok_or("combine: missing --out")?;
|
||||
let inputs = positional(args, COMBINE_OPTIONS);
|
||||
if inputs.len() < 2 {
|
||||
return Err("combine: give at least two input files".into());
|
||||
}
|
||||
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
|
||||
let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("extract", args, EXTRACT_OPTIONS)?;
|
||||
let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?;
|
||||
let out = flag(args, "--out").ok_or("extract: missing --out")?;
|
||||
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn split(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("split", args, SPLIT_OPTIONS)?;
|
||||
use pdfcraft_engine::SplitBy;
|
||||
let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?;
|
||||
let by = match (flag(args, "--every"), flag(args, "--before")) {
|
||||
(Some(n), None) => {
|
||||
// A count the splitter cannot hold is refused here, before the file is opened,
|
||||
// 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()),
|
||||
};
|
||||
let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let stem = file_stem(path);
|
||||
let parts = session.split(id, &by).map_err(|e| e.to_string())?;
|
||||
// An output folder that does not exist yet is created (#249); one that can't be is reported
|
||||
// as the folder's problem, not as the first part's.
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
|
||||
for (a, b, bytes) in parts {
|
||||
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
|
||||
write_pdf(&name, &bytes)?;
|
||||
stdout_line(format_args!("{}", name.display()))?;
|
||||
}
|
||||
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.
|
||||
pub(crate) 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:?}"))
|
||||
}
|
||||
|
||||
pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("render", args, RENDER_OPTIONS)?;
|
||||
let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?;
|
||||
let page: usize =
|
||||
flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
|
||||
let page_index = page - 1;
|
||||
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
|
||||
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
|
||||
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
|
||||
let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("png") => Some(ImageFormat::Png),
|
||||
Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }),
|
||||
Some("tif" | "tiff") => Some(ImageFormat::Tiff),
|
||||
Some("pam") => None,
|
||||
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
|
||||
};
|
||||
// The app renders a display copy that draws comments missing their /AP streams; the CLI
|
||||
// must agree with it (and with independent viewers), while keeping the file it reads intact.
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
// 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 {
|
||||
// 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();
|
||||
pam.extend_from_slice(&p.rgba);
|
||||
pam
|
||||
}
|
||||
};
|
||||
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
|
||||
for warning in &p.warnings {
|
||||
match warning {
|
||||
pdfcraft_render::RenderWarning::ContentTruncated => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
|
||||
}
|
||||
pdfcraft_render::RenderWarning::ColourFallback => {
|
||||
let _ =
|
||||
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+14
-1052
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,148 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct OptionSpec {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) takes_value: bool,
|
||||
}
|
||||
|
||||
/// The options whose value is a file or folder. Only these refuse a value that is another
|
||||
/// option: a file named `--password` is never what was meant, while a password can be anything
|
||||
/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title.
|
||||
/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered.
|
||||
const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"];
|
||||
|
||||
/// Whether a token can be the value of a path option. Another option cannot: `--out --password`
|
||||
/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944).
|
||||
pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool {
|
||||
!PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// The complaint about a path option whose value is missing or is another option.
|
||||
pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name")
|
||||
}
|
||||
_ => format!("{command}: {name} needs a value"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse unknown options and malformed confinement flags before any file is read.
|
||||
pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
|
||||
if specs.iter().any(|spec| spec.name == "--root") {
|
||||
// `flag` searches every token, including another option's value. Match that lookup so
|
||||
// a malformed confinement flag cannot be hidden there and fall back to no root.
|
||||
let mut root_seen = false;
|
||||
for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") {
|
||||
if root_seen {
|
||||
return Err(format!("{command}: --root may only be supplied once").into());
|
||||
}
|
||||
root_seen = true;
|
||||
if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) {
|
||||
return Err(format!("{command}: --root needs a directory value").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
if let Some(name) = arg.strip_prefix("--") {
|
||||
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
|
||||
return Err(format!("{command}: unknown option --{name}").into());
|
||||
};
|
||||
// A file's name is not another option, which also closes the gap the walk left for
|
||||
// those: an unknown option in that value position was skipped along with it. Other
|
||||
// values, missing or malformed, are left to the command, which reports them in its
|
||||
// own terms (`text` and `extract` explain what a page value must be).
|
||||
i += if spec.takes_value {
|
||||
let value = args.get(i + 1).map(String::as_str);
|
||||
if !can_be_path_value(spec.name, value) {
|
||||
return Err(needs_value(command, spec.name, value).into());
|
||||
}
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
|
||||
|
||||
pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
|
||||
|
||||
pub(crate) const TEXT_OPTIONS: &[OptionSpec] =
|
||||
&[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
|
||||
|
||||
pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
|
||||
|
||||
pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--out", takes_value: true },
|
||||
OptionSpec { name: "--pages", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
];
|
||||
|
||||
pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--every", takes_value: true },
|
||||
OptionSpec { name: "--before", takes_value: true },
|
||||
OptionSpec { name: "--out-dir", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
];
|
||||
|
||||
pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--dpi", takes_value: true },
|
||||
OptionSpec { name: "--out", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
];
|
||||
|
||||
pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] =
|
||||
&[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }];
|
||||
|
||||
pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--timeout", takes_value: true },
|
||||
OptionSpec { name: "--dpi", takes_value: true },
|
||||
OptionSpec { name: "--json", takes_value: true },
|
||||
];
|
||||
|
||||
pub(crate) const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--root", takes_value: true },
|
||||
OptionSpec { name: "--script", takes_value: true },
|
||||
OptionSpec { name: "--out", takes_value: true },
|
||||
OptionSpec { name: "--report", takes_value: false },
|
||||
];
|
||||
|
||||
pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub(crate) const MCP_OPTIONS: &[OptionSpec] =
|
||||
&[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
|
||||
|
||||
/// The arguments that are not options or their values. Which tokens are values is the spec's
|
||||
/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing
|
||||
/// file", the file having been read as `--edit`'s value.
|
||||
pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
match specs.iter().find(|spec| spec.name == a.as_str()) {
|
||||
Some(spec) if spec.takes_value => i += 2,
|
||||
// An option that takes no value, and an unknown one, which `validate_options` has
|
||||
// already refused by the time this runs.
|
||||
Some(_) => i += 1,
|
||||
None if a.starts_with("--") => i += 1,
|
||||
None => {
|
||||
out.push(a.as_str());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
#[cfg(feature = "mcp")]
|
||||
use super::options::MCP_OPTIONS;
|
||||
use super::options::{RUN_OPTIONS, flag, positional, validate_options};
|
||||
use super::*;
|
||||
|
||||
// ---- automation --------------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn automation(args: &[String]) -> Result<pdfcraft_automation::Automation, String> {
|
||||
let a = pdfcraft_automation::Automation::new();
|
||||
match flag(args, "--root") {
|
||||
Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
|
||||
None => Ok(a),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tools() -> Result<(), CliError> {
|
||||
let list: Vec<serde_json::Value> = pdfcraft_automation::tools()
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
|
||||
.collect();
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
|
||||
/// written like a tool's own output, so `--root` confines it too.
|
||||
pub(crate) fn print_output(
|
||||
auto: &pdfcraft_automation::Automation,
|
||||
content: Vec<pdfcraft_automation::Content>,
|
||||
out: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
for c in content {
|
||||
match c {
|
||||
pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
|
||||
pdfcraft_automation::Content::Png { data, width, height } => match out {
|
||||
Some(path) => {
|
||||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||||
stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
|
||||
}
|
||||
None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a PNG result is reported as when it isn't written to a file.
|
||||
fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
|
||||
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
|
||||
}
|
||||
|
||||
/// A tool's result as JSON values, for the `--report` of a script. Same content as
|
||||
/// [`print_output`], collected instead of printed.
|
||||
fn output_values(
|
||||
auto: &pdfcraft_automation::Automation,
|
||||
content: Vec<pdfcraft_automation::Content>,
|
||||
out: Option<&str>,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let mut values = Vec::new();
|
||||
for c in content {
|
||||
values.push(match c {
|
||||
pdfcraft_automation::Content::Json(v) => v,
|
||||
pdfcraft_automation::Content::Png { data, width, height } => match out {
|
||||
Some(path) => {
|
||||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||||
serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
|
||||
}
|
||||
None => png_summary(&data, width, height),
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
|
||||
/// with the result or the message. The first failing step ends the run, as without `--report`;
|
||||
/// the steps after it are listed as skipped and the exit status is the same.
|
||||
fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
|
||||
let mut report = Vec::with_capacity(steps.len());
|
||||
let mut failure: Option<String> = None;
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let tool = step["tool"].as_str();
|
||||
if failure.is_some() {
|
||||
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
|
||||
continue;
|
||||
}
|
||||
let outcome = match tool {
|
||||
None => Err(format!("step {n}: missing \"tool\"")),
|
||||
Some(t) => auto
|
||||
.call(t, &step["args"])
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|content| output_values(auto, content, step["out"].as_str()))
|
||||
.map_err(|e| format!("step {n} ({t}): {e}")),
|
||||
};
|
||||
match outcome {
|
||||
Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
|
||||
Err(message) => {
|
||||
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
|
||||
failure = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
|
||||
failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
|
||||
}
|
||||
|
||||
pub(crate) fn run(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("run", args, RUN_OPTIONS)?;
|
||||
let report = args.iter().any(|a| a == "--report");
|
||||
if report && flag(args, "--script").is_none() {
|
||||
return Err("run: --report needs --script".to_string().into());
|
||||
}
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(script) = flag(args, "--script") {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
|
||||
if report {
|
||||
return run_script_report(&mut auto, &steps);
|
||||
}
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
|
||||
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
|
||||
print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
|
||||
CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
|
||||
stdout => stdout,
|
||||
})?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
|
||||
let mut obj = serde_json::Map::new();
|
||||
for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
|
||||
let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
|
||||
let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
|
||||
obj.insert(k.to_string(), value);
|
||||
}
|
||||
let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
|
||||
print_output(&auto, content, flag(args, "--out"))
|
||||
}
|
||||
|
||||
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
|
||||
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
|
||||
/// that are left. A malformed root flag is an error, never a root silently left out.
|
||||
#[cfg(feature = "mcp")]
|
||||
fn automation_roots(command: &str, args: &[String]) -> Result<(Option<Arc<pdfcraft_automation::file_access::AutomationRoots>>, Vec<String>), String> {
|
||||
let mut found = automation_args::AutomationArgs::new(None, None);
|
||||
let mut left = Vec::new();
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(arg) = it.next() {
|
||||
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
|
||||
left.push(arg);
|
||||
}
|
||||
}
|
||||
let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
|
||||
.map_err(|e| format!("{command}: {e}"))?;
|
||||
Ok((roots, left))
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> {
|
||||
let (roots, args) = automation_roots("mcp", args)?;
|
||||
let args = args.as_slice();
|
||||
validate_options("mcp", args, MCP_OPTIONS)?;
|
||||
let compact = args.iter().any(|a| a == "--compact");
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(roots) = roots {
|
||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: files confined: read root {}, write root {}",
|
||||
show(roots.read_root()),
|
||||
show(roots.write_root())
|
||||
);
|
||||
auto = auto.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
|
||||
pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0],
|
||||
if compact { ", compact tool list" } else { "" }
|
||||
);
|
||||
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use super::options::{UI_OPTIONS, flag, positional, validate_options};
|
||||
use super::*;
|
||||
|
||||
// ---- UI control channel client -----------------------------------------------------------------
|
||||
|
||||
/// One request to a running app's control channel (`pdfcraft --control FILE`).
|
||||
pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("ui", args, UI_OPTIONS)?;
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
|
||||
let info = read_control_file(file)?;
|
||||
let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
|
||||
// Refused rather than truncated, which would quietly reach some other port.
|
||||
let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
|
||||
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
|
||||
let pos = positional(args, UI_OPTIONS);
|
||||
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
|
||||
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
|
||||
let mut params = serde_json::Map::new();
|
||||
for kv in pos.iter().skip(1) {
|
||||
let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
|
||||
params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
|
||||
}
|
||||
let stream =
|
||||
std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
|
||||
let mut write = stream.try_clone().map_err(|e| e.to_string())?;
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result<serde_json::Value, String> {
|
||||
writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
|
||||
let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
|
||||
let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
|
||||
match reply.get("error") {
|
||||
Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
|
||||
None => Ok(reply["result"].clone()),
|
||||
}
|
||||
};
|
||||
rpc(1, "auth", serde_json::json!({ "token": token }))?;
|
||||
let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
|
||||
if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
|
||||
use base64::Engine as _;
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
|
||||
result["png_base64"] = serde_json::json!(format!("written to {out}"));
|
||||
}
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Most a control file may hold. The app writes under 100 bytes.
|
||||
const CONTROL_FILE_MAX: u64 = 64 * 1024;
|
||||
|
||||
const CONTROL_FILE_ADVICE: &str =
|
||||
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
|
||||
|
||||
/// Read the file written by `pdfcraft --control FILE`.
|
||||
///
|
||||
/// It names the port that receives the token and every command, typed text included, so it must
|
||||
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
|
||||
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
|
||||
/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
|
||||
/// else or open to other users (the app writes it with mode 600).
|
||||
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
|
||||
use std::io::Read as _;
|
||||
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
if at_path.file_type().is_symlink() {
|
||||
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
if !at_path.is_file() {
|
||||
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
|
||||
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
|
||||
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
|
||||
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
|
||||
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
|
||||
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
|
||||
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))
|
||||
}
|
||||
|
||||
/// Why user `me` can't trust a control file with this owner and mode, if they can't.
|
||||
#[cfg(any(test, unix))]
|
||||
fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option<String> {
|
||||
if owner != me {
|
||||
return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands"));
|
||||
}
|
||||
let permissions = mode & 0o7777;
|
||||
(permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)"))
|
||||
}
|
||||
|
||||
/// The effective user id: the owner of the files this process creates, and so of a control file
|
||||
/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is
|
||||
/// safe and can't fail.
|
||||
#[cfg(unix)]
|
||||
fn current_uid() -> u32 {
|
||||
rustix::process::geteuid().as_raw()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::control_file_problem;
|
||||
|
||||
#[test]
|
||||
fn a_control_file_must_be_yours_and_private() {
|
||||
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
|
||||
assert_eq!(control_file_problem(1000, 0o100600, 1000), None);
|
||||
assert_eq!(control_file_problem(1000, 0o600, 1000), None);
|
||||
assert_eq!(control_file_problem(1000, 0o400, 1000), None);
|
||||
let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default();
|
||||
assert!(other.contains("owned by another user") && other.contains("1001"), "{other}");
|
||||
// Root gets no exception: a file a user planted is still theirs.
|
||||
assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user")));
|
||||
for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] {
|
||||
let open = control_file_problem(1000, mode, 1000).unwrap_or_default();
|
||||
assert!(open.contains("other users"), "{mode:o}: {open}");
|
||||
}
|
||||
assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644"));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
|
||||
egui = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -11,15 +11,13 @@ workspace = true
|
||||
[dependencies]
|
||||
# Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's
|
||||
# trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed).
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
|
||||
rustls-native-certs = "0.8"
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
# `build_info`: the version and compile-time build label for `--version` and the log.
|
||||
pdfcraft-engine = { workspace = true }
|
||||
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
|
||||
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde_json = { workspace = true }
|
||||
# Records from every crate go to standard error and the log file (src/logging.rs).
|
||||
log = { workspace = true }
|
||||
@@ -31,9 +29,6 @@ chrono = { workspace = true }
|
||||
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
|
||||
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
|
||||
# window to close instead of winit's, which ends the process without asking.
|
||||
craft-quit = { workspace = true, features = ["menu"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
|
||||
@@ -71,3 +66,6 @@ uds_windows = "=1.2.1"
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
+15
-674
@@ -41,7 +41,6 @@ mod reopen;
|
||||
mod settings;
|
||||
mod single_instance;
|
||||
mod text_scale;
|
||||
mod updates;
|
||||
#[cfg(test)]
|
||||
#[path = "windows_manifest.rs"]
|
||||
mod windows_manifest;
|
||||
@@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec<String>, exists: impl Fn(&str) -> bool) -> Vec<S
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
// Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report
|
||||
@@ -357,6 +361,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
|
||||
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
|
||||
// Set explicitly, so the renderer never depends on which one eframe defaults to.
|
||||
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
|
||||
// PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's
|
||||
// provider would read every installed face, including proprietary ones, into every UI pass.
|
||||
native.system_font_fallback = false;
|
||||
if renderer == eframe::Renderer::Wgpu {
|
||||
configure_gpu(&mut native);
|
||||
}
|
||||
@@ -416,16 +423,17 @@ fn app_creator<'a>(
|
||||
{
|
||||
app.motion.source = Some(motion::source());
|
||||
}
|
||||
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
|
||||
app.set_startup_update_check(true);
|
||||
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
|
||||
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
|
||||
// menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
|
||||
if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
|
||||
log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
|
||||
// The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as
|
||||
// winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close
|
||||
// button.
|
||||
if !app.install_native_menu(&cc.egui_ctx) {
|
||||
log::warn!("couldn't install the macOS menu bar; the menus stay in the window");
|
||||
}
|
||||
}
|
||||
if let Some(server) = instance {
|
||||
@@ -794,671 +802,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_ui_egui::control::Endpoint;
|
||||
|
||||
fn endpoint() -> std::io::Result<Endpoint> {
|
||||
Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
|
||||
}
|
||||
|
||||
/// Tests that open a real GPU device take this first, so only one of them is inside the
|
||||
/// 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/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(());
|
||||
|
||||
fn gpu() -> std::sync::MutexGuard<'static, ()> {
|
||||
// A test that panicked while holding it leaves nothing to clean up.
|
||||
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_channel_that_cant_be_published_stops_the_app() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
|
||||
|
||||
// A folder in the way stands in for a file another user created first: the write fails.
|
||||
std::fs::create_dir(dir.join("taken.json")).unwrap();
|
||||
let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
|
||||
assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
|
||||
// No listener: nothing to publish.
|
||||
let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
|
||||
assert!(err.contains("no loopback"), "{err}");
|
||||
assert!(!dir.join("pc.json").exists());
|
||||
|
||||
super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
|
||||
let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
|
||||
assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_from_arg_decodes_file_uris() {
|
||||
assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
|
||||
assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf");
|
||||
assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf");
|
||||
// Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI.
|
||||
assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf");
|
||||
assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2");
|
||||
assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%");
|
||||
assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquoted_paths_with_spaces_are_rejoined() {
|
||||
let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
|
||||
let exists = |p: &str| have.contains(&p);
|
||||
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
// The case an office program produced: one path, split at its space.
|
||||
assert_eq!(
|
||||
super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists),
|
||||
args(&["P:\\Archive\\00042A26 Contract.PDF"])
|
||||
);
|
||||
// The longest run wins; files that exist on their own are left alone.
|
||||
assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
|
||||
assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
|
||||
// Nothing to join: kept as given, so the missing file is still reported by name.
|
||||
assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
|
||||
assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
|
||||
assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
|
||||
assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
|
||||
assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf");
|
||||
assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf");
|
||||
assert_eq!(super::path_from_arg("file://".to_string()), "file://");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
|
||||
use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
|
||||
assert_eq!(renderer_choice(None), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
|
||||
for gl in ["gl", "OpenGL", "glow"] {
|
||||
assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
|
||||
}
|
||||
// Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
|
||||
assert!(retry_with_gl(RendererChoice::Auto, false));
|
||||
assert!(!retry_with_gl(RendererChoice::Auto, true));
|
||||
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
|
||||
assert!(!retry_with_gl(RendererChoice::Gl, false));
|
||||
// Each run states its renderer rather than relying on eframe's default.
|
||||
assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
|
||||
assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
|
||||
use super::native_options;
|
||||
assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
|
||||
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_software_renderer_is_shown_to_the_user() {
|
||||
use eframe::wgpu::DeviceType;
|
||||
// Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log.
|
||||
let mut app = super::PdfCraftApp::new();
|
||||
super::notify_software_renderer(&mut app, DeviceType::Cpu);
|
||||
let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default();
|
||||
assert!(notice.contains("without a graphics processor"), "{notice:?}");
|
||||
for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] {
|
||||
let mut app = super::PdfCraftApp::new();
|
||||
super::notify_software_renderer(&mut app, gpu);
|
||||
assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 {
|
||||
panic!("default setup creates its own instance")
|
||||
};
|
||||
if std::env::var_os("WGPU_POWER_PREF").is_none() {
|
||||
assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
|
||||
}
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let flags = setup.instance_descriptor.flags;
|
||||
assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
|
||||
}
|
||||
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
let backends = setup.instance_descriptor.backends;
|
||||
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]
|
||||
fn restored_hidpi_window_fits_the_adapters_surface_limit() {
|
||||
// The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface.
|
||||
let (width, height) = (8600, 3423);
|
||||
let required = eframe::wgpu::Limits::default();
|
||||
assert!(width > required.max_texture_dimension_2d);
|
||||
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() };
|
||||
let enabled = super::surface_texture_limits(required, &supported);
|
||||
assert!(width <= enabled.max_texture_dimension_2d);
|
||||
assert!(height <= enabled.max_texture_dimension_2d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
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;
|
||||
// Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this.
|
||||
let (width, height, scale) = surface_fit(8600, 3423, 8192);
|
||||
assert_eq!((width, height), (8192, 3260));
|
||||
// Both sides shrink alike and egui draws at that factor: the whole window is drawn.
|
||||
assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}");
|
||||
assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}");
|
||||
// Within the limit nothing changes, up to and including the limit itself.
|
||||
assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0));
|
||||
assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0));
|
||||
assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0));
|
||||
// One side over the limit: the window as it would be stretched across monitors.
|
||||
assert_eq!(surface_fit(8193, 600, 8192).0, 8192);
|
||||
assert_eq!(surface_fit(600, 8193, 8192).1, 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_fit_never_empties_or_overflows_a_surface() {
|
||||
use eframe::egui_wgpu::winit::surface_fit;
|
||||
for (w, h, max) in [
|
||||
(1, u32::MAX, 8192),
|
||||
(u32::MAX, 1, 2048),
|
||||
(u32::MAX, u32::MAX, 16384),
|
||||
(0, u32::MAX, 8192),
|
||||
(u32::MAX, 0, 8192),
|
||||
(5, 7, 0),
|
||||
(40_000, 3, 1),
|
||||
] {
|
||||
let (fw, fh, scale) = surface_fit(w, h, max);
|
||||
assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}");
|
||||
// A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one),
|
||||
// and a zero side stays zero (egui-wgpu skips configuring it).
|
||||
assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}");
|
||||
assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}");
|
||||
}
|
||||
// The longer side lands exactly on the limit (rounding never leaves it a pixel short),
|
||||
// and the shorter side keeps the window's proportions to within a pixel.
|
||||
for max in [2048, 8192, 16384] {
|
||||
for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) {
|
||||
for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] {
|
||||
let (fw, fh, scale) = surface_fit(long, short, max);
|
||||
assert_eq!(fw, max, "{long} x {short} in {max}");
|
||||
let expected = f64::from(short) * f64::from(max) / f64::from(long);
|
||||
assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}");
|
||||
assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_wgpu_carries_the_surface_size_fit() {
|
||||
// Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and
|
||||
// `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
|
||||
// (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that
|
||||
// resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would
|
||||
// bring the crash back: re-apply the patch, or drop the copy once an egui release has a
|
||||
// fix (vendor/README.md).
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
|
||||
assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
|
||||
// `surface_fit` is tested above; these keep it in the paths that size a surface.
|
||||
let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
|
||||
for patch in [
|
||||
"let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);",
|
||||
"let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());",
|
||||
"pixels_per_point: pixels_per_point * surface_state.render_scale,",
|
||||
"old_state.window_width,\n old_state.window_height,",
|
||||
] {
|
||||
assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() {
|
||||
// Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's
|
||||
// surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was
|
||||
// created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new
|
||||
// window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings,
|
||||
// and an error wgpu raises before anything reaches the driver: a texture one pixel wider
|
||||
// than the device allows.
|
||||
use eframe::wgpu;
|
||||
let _gpu = gpu();
|
||||
let native = super::native_options(false, false, eframe::Renderer::Wgpu);
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
|
||||
panic!("default setup creates its own instance")
|
||||
};
|
||||
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
|
||||
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
|
||||
// CI runners have a software adapter (WARP, llvmpipe); a machine without any skips.
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
|
||||
eprintln!("skipping: no GPU adapter on this machine");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else {
|
||||
eprintln!("skipping: the adapter gives no device");
|
||||
return;
|
||||
};
|
||||
let texture = |width: u32| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("issue-519"),
|
||||
size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1);
|
||||
let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
|
||||
assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}");
|
||||
// The device stays usable, and nothing is left behind to catch later errors by mistake.
|
||||
let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16)));
|
||||
assert!(fine.is_ok(), "{fine:?}");
|
||||
assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7));
|
||||
// Nested: the inner call keeps its own error, and the outer one still catches what follows.
|
||||
let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || {
|
||||
let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
|
||||
assert!(inner.is_err(), "inner: {inner:?}");
|
||||
texture(too_wide)
|
||||
}));
|
||||
assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() {
|
||||
// Issue #519: `catch_errors` is tested above; this keeps it around the first configure of
|
||||
// every new window's surface, and its error reaching eframe as `WgpuError` (eframe then
|
||||
// returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure
|
||||
// panics before the app is created, and the OpenGL retry never runs.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
|
||||
assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
|
||||
let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
|
||||
let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface");
|
||||
for patch in [
|
||||
"let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })",
|
||||
"return Err(crate::WgpuError::ConfigureSurface(error));",
|
||||
] {
|
||||
assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
let _gpu = gpu();
|
||||
let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
|
||||
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
|
||||
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
|
||||
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
|
||||
assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
|
||||
eprintln!("skipping pipeline regression: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter)))
|
||||
.expect("the selected adapter must provide a test device");
|
||||
native.wgpu_options.wgpu_setup =
|
||||
egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
|
||||
// Three samples is invalid on every backend, so this reproduces a pipeline error
|
||||
// without depending on a particular driver. The unpatched constructor panics.
|
||||
for samples in [3, 1, 3, 1] {
|
||||
let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
|
||||
let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
|
||||
if samples == 3 {
|
||||
let error = result.err().expect("invalid pipeline must return an error");
|
||||
assert!(error.to_string().contains("egui_pipeline"), "{error}");
|
||||
} else {
|
||||
assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winit_carries_the_windows_11_monitor_scale_fix() {
|
||||
// Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
|
||||
// scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
|
||||
// monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
|
||||
// dependency bump that resolves winit from crates.io again, or a re-vendored copy without
|
||||
// the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
|
||||
// winit 0.30 release has the fix (vendor/README.md).
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock");
|
||||
assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}");
|
||||
let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
|
||||
let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;";
|
||||
assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch");
|
||||
}
|
||||
|
||||
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
|
||||
const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
|
||||
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
|
||||
|
||||
fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
|
||||
use eframe::wgpu::DeviceType;
|
||||
vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
|
||||
}
|
||||
|
||||
const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
|
||||
|
||||
fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
|
||||
super::DisplayGpu { pci, primary: false }
|
||||
}
|
||||
|
||||
fn panel(pci: (u32, u32)) -> super::DisplayGpu {
|
||||
super::DisplayGpu { pci, primary: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
|
||||
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0));
|
||||
// Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
|
||||
// the display driver. Windows also says which display is the primary one.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
|
||||
// A Windows desktop with a monitor on each GPU: the window opens on the primary display.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pci_ids_come_from_windows_device_ids() {
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
|
||||
assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
|
||||
// Remote Desktop and other non-PCI display devices, and malformed ids.
|
||||
assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None);
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
|
||||
assert_eq!(super::pci_ids("VEN_&DEV_"), None);
|
||||
assert_eq!(super::pci_ids(""), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_display_gpus_lists_pci_gpus_with_a_monitor() {
|
||||
// Whatever this machine has: every entry is a PCI GPU, at most one drives the primary
|
||||
// display, and no GPU is listed twice. (A headless CI runner may list none.)
|
||||
let gpus = super::windows_display_gpus();
|
||||
assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
|
||||
for (i, g) in gpus.iter().enumerate() {
|
||||
assert!(g.pci.0 != 0, "{gpus:?}");
|
||||
assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
|
||||
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
|
||||
// Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
|
||||
use eframe::wgpu::DeviceType;
|
||||
let desktop =
|
||||
[(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
|
||||
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1));
|
||||
// A GPU driving no display still loses to one that does.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
|
||||
// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
|
||||
// `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
|
||||
// compositor shows nothing it draws, and reports no error either.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
|
||||
// The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
|
||||
// No GPU drives a display as far as sysfs says (a container), but the compositor knows.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
|
||||
// A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_falls_back_to_low_power_without_a_match() {
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_panels_are_edp_lvds_and_dsi_connectors() {
|
||||
for name in ["eDP-1", "LVDS-1", "DSI-1"] {
|
||||
assert!(super::is_internal_panel(name), "{name}");
|
||||
}
|
||||
for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
|
||||
assert!(!super::is_internal_panel(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name).join("device"))?;
|
||||
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
|
||||
std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
|
||||
};
|
||||
let connector = |name: &str, status: &str| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name))?;
|
||||
std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
|
||||
};
|
||||
card("card1", NVIDIA)?;
|
||||
card("card2", AMD_IGPU)?;
|
||||
connector("card1-DP-3", "connected")?;
|
||||
connector("card1-DP-4", "connected")?;
|
||||
connector("card2-HDMI-A-1", "disconnected")?;
|
||||
connector("card2-Writeback-1", "unknown")?;
|
||||
let desktop = super::linux_display_gpus(&dir);
|
||||
// Issue #445: a monitor on the integrated GPU too, still no built-in panel.
|
||||
connector("card2-HDMI-A-1", "connected")?;
|
||||
let mut both = super::linux_display_gpus(&dir);
|
||||
// A hybrid laptop: the integrated GPU drives the built-in panel as well.
|
||||
connector("card2-eDP-1", "connected")?;
|
||||
let mut laptop = super::linux_display_gpus(&dir);
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
assert_eq!(desktop, vec![monitor(NVIDIA)]);
|
||||
both.sort_by_key(|g| g.pci);
|
||||
assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
|
||||
laptop.sort_by_key(|g| g.pci);
|
||||
assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
|
||||
assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fresh folder per call: tests run in parallel.
|
||||
fn scratch() -> std::path::PathBuf {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The control file's port and token, and every name in its folder.
|
||||
fn written(dir: &std::path::Path) -> (serde_json::Value, Vec<String>) {
|
||||
let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap();
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
(json, names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() {
|
||||
// The file used to be opened with create + truncate, so a link planted at the path had its
|
||||
// target truncated and overwritten with the token.
|
||||
let dir = scratch();
|
||||
std::fs::write(dir.join("victim.txt"), "keep me").unwrap();
|
||||
std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap();
|
||||
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me");
|
||||
let (json, names) = written(&dir);
|
||||
assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken")));
|
||||
assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() {
|
||||
let dir = scratch();
|
||||
let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt"));
|
||||
std::fs::write(&target, "keep me").unwrap();
|
||||
#[cfg(unix)]
|
||||
let made = std::os::unix::fs::symlink(&target, &link);
|
||||
#[cfg(windows)]
|
||||
let made = std::os::windows::fs::symlink_file(&target, &link);
|
||||
if let Err(e) = made {
|
||||
// Windows needs Developer Mode (or admin) for symlinks.
|
||||
eprintln!("skipped: can't create a symlink here: {e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
|
||||
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_file_replaces_the_one_a_previous_run_left() {
|
||||
let dir = scratch();
|
||||
std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap();
|
||||
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
let (json, names) = written(&dir);
|
||||
assert_eq!(json["token"].as_str(), Some("t0ken"));
|
||||
assert_eq!(names, ["ctl.json"]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
fn the_desktop_entry_names_the_window_class() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
|
||||
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
|
||||
// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
|
||||
// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
|
||||
// path and the hook must write to standard error without it.
|
||||
#[test]
|
||||
fn start_up_and_the_panic_hook_never_use_eprintln() {
|
||||
let source = include_str!("main.rs");
|
||||
// Everything before the top-level test module is product code.
|
||||
let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source);
|
||||
assert!(product.len() < source.len(), "found the end of the product code");
|
||||
let offenders: Vec<_> = product
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!"))
|
||||
.map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim()))
|
||||
.collect();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,686 @@
|
||||
/// The lockfile package block for `name`, or a panic naming the missing package.
|
||||
fn locked_package(root: &std::path::Path, name: &str) -> String {
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock");
|
||||
lock.split("[[package]]")
|
||||
.find(|p| p.contains(&format!("\nname = \"{name}\"\n")))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| panic!("{name} is not in Cargo.lock"))
|
||||
}
|
||||
|
||||
/// Interim graph check for the window stack while the reviewed fork revision is not yet
|
||||
/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the
|
||||
/// published manifest; local integration supplies the reviewed worktrees through an external
|
||||
/// Cargo config patch. A git source must name the public storytold fork; a path source is the
|
||||
/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested
|
||||
/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is
|
||||
/// pinned. No public revision is invented here.
|
||||
fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String {
|
||||
let package = locked_package(root, name);
|
||||
assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}");
|
||||
if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) {
|
||||
assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}");
|
||||
assert!(
|
||||
source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40),
|
||||
"{name} must pin the fork's 40-character revision, not {source}"
|
||||
);
|
||||
}
|
||||
// A path patch has no `source =` in Cargo.lock. The local integration config is the only
|
||||
// allowed path source; a machine-local path must never be in the published manifest,
|
||||
// which the root-manifest assertion below checks. A path entry carries no remote identity
|
||||
// to verify, so this test does not claim the resolved code is the published fork.
|
||||
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml");
|
||||
assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch");
|
||||
package
|
||||
}
|
||||
|
||||
use pdfcraft_ui_egui::control::Endpoint;
|
||||
|
||||
fn endpoint() -> std::io::Result<Endpoint> {
|
||||
Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
|
||||
}
|
||||
|
||||
/// Tests that open a real GPU device take this first, so only one of them is inside the
|
||||
/// 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/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(());
|
||||
|
||||
fn gpu() -> std::sync::MutexGuard<'static, ()> {
|
||||
// A test that panicked while holding it leaves nothing to clean up.
|
||||
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_channel_that_cant_be_published_stops_the_app() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
|
||||
|
||||
// A folder in the way stands in for a file another user created first: the write fails.
|
||||
std::fs::create_dir(dir.join("taken.json")).unwrap();
|
||||
let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
|
||||
assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
|
||||
// No listener: nothing to publish.
|
||||
let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
|
||||
assert!(err.contains("no loopback"), "{err}");
|
||||
assert!(!dir.join("pc.json").exists());
|
||||
|
||||
super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
|
||||
let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
|
||||
assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_from_arg_decodes_file_uris() {
|
||||
assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
|
||||
assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf");
|
||||
assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf");
|
||||
// Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI.
|
||||
assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf");
|
||||
assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2");
|
||||
assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%");
|
||||
assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquoted_paths_with_spaces_are_rejoined() {
|
||||
let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
|
||||
let exists = |p: &str| have.contains(&p);
|
||||
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
// The case an office program produced: one path, split at its space.
|
||||
assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"]));
|
||||
// The longest run wins; files that exist on their own are left alone.
|
||||
assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
|
||||
assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
|
||||
// Nothing to join: kept as given, so the missing file is still reported by name.
|
||||
assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
|
||||
assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
|
||||
assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
|
||||
assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
|
||||
assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf");
|
||||
assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf");
|
||||
assert_eq!(super::path_from_arg("file://".to_string()), "file://");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
|
||||
use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
|
||||
assert_eq!(renderer_choice(None), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
|
||||
for gl in ["gl", "OpenGL", "glow"] {
|
||||
assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
|
||||
}
|
||||
// Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
|
||||
assert!(retry_with_gl(RendererChoice::Auto, false));
|
||||
assert!(!retry_with_gl(RendererChoice::Auto, true));
|
||||
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
|
||||
assert!(!retry_with_gl(RendererChoice::Gl, false));
|
||||
// Each run states its renderer rather than relying on eframe's default.
|
||||
assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
|
||||
assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
|
||||
use super::native_options;
|
||||
assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
|
||||
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_software_renderer_is_shown_to_the_user() {
|
||||
use eframe::wgpu::DeviceType;
|
||||
// Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log.
|
||||
let mut app = super::PdfCraftApp::new();
|
||||
super::notify_software_renderer(&mut app, DeviceType::Cpu);
|
||||
let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default();
|
||||
assert!(notice.contains("without a graphics processor"), "{notice:?}");
|
||||
for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] {
|
||||
let mut app = super::PdfCraftApp::new();
|
||||
super::notify_software_renderer(&mut app, gpu);
|
||||
assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 { panic!("default setup creates its own instance") };
|
||||
if std::env::var_os("WGPU_POWER_PREF").is_none() {
|
||||
assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
|
||||
}
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let flags = setup.instance_descriptor.flags;
|
||||
assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
|
||||
}
|
||||
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
let backends = setup.instance_descriptor.backends;
|
||||
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]
|
||||
fn restored_hidpi_window_fits_the_adapters_surface_limit() {
|
||||
// The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface.
|
||||
let (width, height) = (8600, 3423);
|
||||
let required = eframe::wgpu::Limits::default();
|
||||
assert!(width > required.max_texture_dimension_2d);
|
||||
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() };
|
||||
let enabled = super::surface_texture_limits(required, &supported);
|
||||
assert!(width <= enabled.max_texture_dimension_2d);
|
||||
assert!(height <= enabled.max_texture_dimension_2d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
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;
|
||||
// Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this.
|
||||
let (width, height, scale) = surface_fit(8600, 3423, 8192);
|
||||
assert_eq!((width, height), (8192, 3260));
|
||||
// Both sides shrink alike and egui draws at that factor: the whole window is drawn.
|
||||
assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}");
|
||||
assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}");
|
||||
// Within the limit nothing changes, up to and including the limit itself.
|
||||
assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0));
|
||||
assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0));
|
||||
assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0));
|
||||
// One side over the limit: the window as it would be stretched across monitors.
|
||||
assert_eq!(surface_fit(8193, 600, 8192).0, 8192);
|
||||
assert_eq!(surface_fit(600, 8193, 8192).1, 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_fit_never_empties_or_overflows_a_surface() {
|
||||
use eframe::egui_wgpu::winit::surface_fit;
|
||||
for (w, h, max) in
|
||||
[(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)]
|
||||
{
|
||||
let (fw, fh, scale) = surface_fit(w, h, max);
|
||||
assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}");
|
||||
// A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one),
|
||||
// and a zero side stays zero (egui-wgpu skips configuring it).
|
||||
assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}");
|
||||
assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}");
|
||||
}
|
||||
// The longer side lands exactly on the limit (rounding never leaves it a pixel short),
|
||||
// and the shorter side keeps the window's proportions to within a pixel.
|
||||
for max in [2048, 8192, 16384] {
|
||||
for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) {
|
||||
for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] {
|
||||
let (fw, fh, scale) = surface_fit(long, short, max);
|
||||
assert_eq!(fw, max, "{long} x {short} in {max}");
|
||||
let expected = f64::from(short) * f64::from(max) / f64::from(long);
|
||||
assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}");
|
||||
assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
|
||||
/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch.
|
||||
#[test]
|
||||
fn vendor_provenance_egui_wgpu_surface_fit_identity() {
|
||||
// Issue #577: egui-wgpu configured a window's surface at the window's size, and
|
||||
// `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
|
||||
// (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the
|
||||
// behavioural test above exercises it. The old 0.36 vendor copy is retained for
|
||||
// provenance only and is no longer a published path patch, so reading it would not prove
|
||||
// what the app runs.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
|
||||
assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() {
|
||||
// Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's
|
||||
// surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was
|
||||
// created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new
|
||||
// window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings,
|
||||
// and an error wgpu raises before anything reaches the driver: a texture one pixel wider
|
||||
// than the device allows.
|
||||
use eframe::wgpu;
|
||||
let _gpu = gpu();
|
||||
let native = super::native_options(false, false, eframe::Renderer::Wgpu);
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
|
||||
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
|
||||
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
|
||||
// CI runners have a software adapter (WARP, llvmpipe); a machine without any skips.
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
|
||||
eprintln!("skipping: no GPU adapter on this machine");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else {
|
||||
eprintln!("skipping: the adapter gives no device");
|
||||
return;
|
||||
};
|
||||
let texture = |width: u32| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("issue-519"),
|
||||
size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1);
|
||||
let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
|
||||
assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}");
|
||||
// The device stays usable, and nothing is left behind to catch later errors by mistake.
|
||||
let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16)));
|
||||
assert!(fine.is_ok(), "{fine:?}");
|
||||
assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7));
|
||||
// Nested: the inner call keeps its own error, and the outer one still catches what follows.
|
||||
let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || {
|
||||
let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
|
||||
assert!(inner.is_err(), "inner: {inner:?}");
|
||||
texture(too_wide)
|
||||
}));
|
||||
assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
|
||||
}
|
||||
|
||||
/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
|
||||
/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch.
|
||||
#[test]
|
||||
fn vendor_provenance_egui_wgpu_catch_errors_identity() {
|
||||
// Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37
|
||||
// worktree carries it around the first configure of every new window's surface, and its
|
||||
// error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main`
|
||||
// retries with OpenGL). Without it a failed configure panics before the app is created.
|
||||
// `catch_errors` is exercised against a real device in the test above and below; the
|
||||
// graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor
|
||||
// copy is not read as proof of behaviour).
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
|
||||
assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
let _gpu = gpu();
|
||||
let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
|
||||
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
|
||||
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
|
||||
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
|
||||
assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
|
||||
eprintln!("skipping pipeline regression: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device");
|
||||
native.wgpu_options.wgpu_setup =
|
||||
egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
|
||||
// Three samples is invalid on every backend, so this reproduces a pipeline error
|
||||
// without depending on a particular driver. The unpatched constructor panics.
|
||||
for samples in [3, 1, 3, 1] {
|
||||
let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
|
||||
let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
|
||||
if samples == 3 {
|
||||
let error = result.err().expect("invalid pipeline must return an error");
|
||||
assert!(error.to_string().contains("egui_pipeline"), "{error}");
|
||||
} else {
|
||||
assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy
|
||||
/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded.
|
||||
#[test]
|
||||
fn winit_carries_the_windows_11_monitor_scale_fix() {
|
||||
// Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
|
||||
// scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
|
||||
// monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
|
||||
// dependency bump that resolves winit from crates.io again, or a re-vendored copy without
|
||||
// the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
|
||||
// winit 0.30 release has the fix (vendor/README.md).
|
||||
// W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in
|
||||
// `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no
|
||||
// longer a published path patch, so this checks the actual graph, not the old copy.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
assert_window_stack_graph(&root, "winit", "0.30.13");
|
||||
let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
|
||||
// The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the
|
||||
// rect Windows suggests, while Windows 10 keeps the old adjustment.
|
||||
for marker in [
|
||||
"PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests",
|
||||
"if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {",
|
||||
"new_outer_rect = suggested_rect;",
|
||||
] {
|
||||
assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}");
|
||||
}
|
||||
let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap();
|
||||
assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance");
|
||||
}
|
||||
|
||||
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
|
||||
const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
|
||||
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
|
||||
|
||||
fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
|
||||
use eframe::wgpu::DeviceType;
|
||||
vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
|
||||
}
|
||||
|
||||
const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
|
||||
|
||||
fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
|
||||
super::DisplayGpu { pci, primary: false }
|
||||
}
|
||||
|
||||
fn panel(pci: (u32, u32)) -> super::DisplayGpu {
|
||||
super::DisplayGpu { pci, primary: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
|
||||
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0));
|
||||
// Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
|
||||
// the display driver. Windows also says which display is the primary one.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
|
||||
// A Windows desktop with a monitor on each GPU: the window opens on the primary display.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pci_ids_come_from_windows_device_ids() {
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
|
||||
assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
|
||||
// Remote Desktop and other non-PCI display devices, and malformed ids.
|
||||
assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None);
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
|
||||
assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
|
||||
assert_eq!(super::pci_ids("VEN_&DEV_"), None);
|
||||
assert_eq!(super::pci_ids(""), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_display_gpus_lists_pci_gpus_with_a_monitor() {
|
||||
// Whatever this machine has: every entry is a PCI GPU, at most one drives the primary
|
||||
// display, and no GPU is listed twice. (A headless CI runner may list none.)
|
||||
let gpus = super::windows_display_gpus();
|
||||
assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
|
||||
for (i, g) in gpus.iter().enumerate() {
|
||||
assert!(g.pci.0 != 0, "{gpus:?}");
|
||||
assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
|
||||
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
|
||||
// Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
|
||||
use eframe::wgpu::DeviceType;
|
||||
let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
|
||||
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1));
|
||||
// A GPU driving no display still loses to one that does.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
|
||||
// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
|
||||
// `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
|
||||
// compositor shows nothing it draws, and reports no error either.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
|
||||
// The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
|
||||
// No GPU drives a display as far as sysfs says (a container), but the compositor knows.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
|
||||
// A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_falls_back_to_low_power_without_a_match() {
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
|
||||
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_panels_are_edp_lvds_and_dsi_connectors() {
|
||||
for name in ["eDP-1", "LVDS-1", "DSI-1"] {
|
||||
assert!(super::is_internal_panel(name), "{name}");
|
||||
}
|
||||
for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
|
||||
assert!(!super::is_internal_panel(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name).join("device"))?;
|
||||
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
|
||||
std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
|
||||
};
|
||||
let connector = |name: &str, status: &str| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name))?;
|
||||
std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
|
||||
};
|
||||
card("card1", NVIDIA)?;
|
||||
card("card2", AMD_IGPU)?;
|
||||
connector("card1-DP-3", "connected")?;
|
||||
connector("card1-DP-4", "connected")?;
|
||||
connector("card2-HDMI-A-1", "disconnected")?;
|
||||
connector("card2-Writeback-1", "unknown")?;
|
||||
let desktop = super::linux_display_gpus(&dir);
|
||||
// Issue #445: a monitor on the integrated GPU too, still no built-in panel.
|
||||
connector("card2-HDMI-A-1", "connected")?;
|
||||
let mut both = super::linux_display_gpus(&dir);
|
||||
// A hybrid laptop: the integrated GPU drives the built-in panel as well.
|
||||
connector("card2-eDP-1", "connected")?;
|
||||
let mut laptop = super::linux_display_gpus(&dir);
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
assert_eq!(desktop, vec![monitor(NVIDIA)]);
|
||||
both.sort_by_key(|g| g.pci);
|
||||
assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
|
||||
laptop.sort_by_key(|g| g.pci);
|
||||
assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
|
||||
assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fresh folder per call: tests run in parallel.
|
||||
fn scratch() -> std::path::PathBuf {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The control file's port and token, and every name in its folder.
|
||||
fn written(dir: &std::path::Path) -> (serde_json::Value, Vec<String>) {
|
||||
let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap();
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
(json, names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() {
|
||||
// The file used to be opened with create + truncate, so a link planted at the path had its
|
||||
// target truncated and overwritten with the token.
|
||||
let dir = scratch();
|
||||
std::fs::write(dir.join("victim.txt"), "keep me").unwrap();
|
||||
std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap();
|
||||
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me");
|
||||
let (json, names) = written(&dir);
|
||||
assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken")));
|
||||
assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() {
|
||||
let dir = scratch();
|
||||
let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt"));
|
||||
std::fs::write(&target, "keep me").unwrap();
|
||||
#[cfg(unix)]
|
||||
let made = std::os::unix::fs::symlink(&target, &link);
|
||||
#[cfg(windows)]
|
||||
let made = std::os::windows::fs::symlink_file(&target, &link);
|
||||
if let Err(e) = made {
|
||||
// Windows needs Developer Mode (or admin) for symlinks.
|
||||
eprintln!("skipped: can't create a symlink here: {e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
|
||||
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_file_replaces_the_one_a_previous_run_left() {
|
||||
let dir = scratch();
|
||||
std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap();
|
||||
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
let (json, names) = written(&dir);
|
||||
assert_eq!(json["token"].as_str(), Some("t0ken"));
|
||||
assert_eq!(names, ["ctl.json"]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
fn the_desktop_entry_names_the_window_class() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
|
||||
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
|
||||
// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
|
||||
// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
|
||||
// path and the hook must write to standard error without it.
|
||||
#[test]
|
||||
fn start_up_and_the_panic_hook_never_use_eprintln() {
|
||||
let source = include_str!("main.rs");
|
||||
// Everything before the top-level test module is product code.
|
||||
let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source);
|
||||
assert!(product.len() < source.len(), "found the end of the product code");
|
||||
let offenders: Vec<_> = product
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!"))
|
||||
.map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim()))
|
||||
.collect();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release};
|
||||
|
||||
const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest";
|
||||
|
||||
/// The latest release. The answer is untrusted: its size is capped, and only a page under
|
||||
/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list).
|
||||
pub fn latest_release() -> Result<Release, String> {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(10)))
|
||||
.tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build())
|
||||
.build()
|
||||
.new_agent();
|
||||
let mut response = agent
|
||||
.get(LATEST)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION")))
|
||||
// Read error statuses ourselves: a 403 from GitHub usually means its anonymous
|
||||
// rate limit ran out, and the retry time rides along in a response header.
|
||||
.config()
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.call()
|
||||
.map_err(|e| format!("couldn't reach GitHub ({e})"))?;
|
||||
let status = response.status().as_u16();
|
||||
if status == 403 {
|
||||
let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok());
|
||||
return Err(rate_limited(reset, now_unix()));
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("couldn't reach GitHub (http status: {status})"));
|
||||
}
|
||||
let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?;
|
||||
parse(&body)
|
||||
}
|
||||
|
||||
/// The certificate authorities the operating system trusts.
|
||||
fn os_roots() -> Result<ureq::tls::RootCerts, String> {
|
||||
let found = rustls_native_certs::load_native_certs();
|
||||
let certs: Vec<ureq::tls::Certificate<'static>> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect();
|
||||
if certs.is_empty() {
|
||||
return Err("no trusted certificates found on this system".into());
|
||||
}
|
||||
Ok(ureq::tls::RootCerts::new_with_certs(&certs))
|
||||
}
|
||||
|
||||
/// The current Unix time in seconds (0 when the clock gives nothing usable).
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour,
|
||||
/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset`
|
||||
/// header, the Unix time the quota refills; `now` is the current Unix time. Anything
|
||||
/// surprising (no header, not a number, already past) falls back to asking for patience.
|
||||
fn rate_limited(reset: Option<&str>, now: u64) -> String {
|
||||
const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later";
|
||||
let wait = reset.and_then(|r| r.trim().parse::<u64>().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0);
|
||||
match wait {
|
||||
Some(wait) => {
|
||||
let minutes = wait.div_ceil(60).max(1);
|
||||
let plural = if minutes == 1 { "" } else { "s" };
|
||||
format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}")
|
||||
}
|
||||
None => FALLBACK.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(body: &str) -> Result<Release, String> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?;
|
||||
let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
|
||||
let url = v["html_url"]
|
||||
.as_str()
|
||||
.filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\'])))
|
||||
.unwrap_or(RELEASES_PAGE)
|
||||
.to_string();
|
||||
Ok(Release { version, url })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn answers_are_read_and_only_our_release_pages_are_offered() {
|
||||
let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap();
|
||||
assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() });
|
||||
for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] {
|
||||
let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap();
|
||||
assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}");
|
||||
}
|
||||
assert!(parse(r#"{"message":"Not Found"}"#).is_err());
|
||||
assert!(parse("<html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_messages_name_the_wait() {
|
||||
let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes";
|
||||
assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited);
|
||||
// Rounds up: 61 seconds out reads as 2 minutes.
|
||||
assert_eq!(
|
||||
rate_limited(Some("1800000061"), 1800000000),
|
||||
"GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes"
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limited(Some("1800000060"), 1800000000),
|
||||
"GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute"
|
||||
);
|
||||
// No header, not a number, or already past: ask for patience instead of a time.
|
||||
let later = "GitHub is limiting update checks for now (shared hourly quota); try again later";
|
||||
for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] {
|
||||
assert_eq!(rate_limited(bad, 1800000000), later);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`).
|
||||
#[test]
|
||||
#[ignore = "needs network access"]
|
||||
fn github_answers_with_the_latest_release() {
|
||||
let r = latest_release().unwrap();
|
||||
assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}");
|
||||
assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
|
||||
mod vendor_graph;
|
||||
mod winit_macos_emoji_picker;
|
||||
mod winit_macos_ime_commit;
|
||||
mod winit_macos_ime_discard;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Dependency-graph verification for the egui 0.37 window stack.
|
||||
//!
|
||||
//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` /
|
||||
//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app
|
||||
//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained
|
||||
//! copies are not path patches. These tests check the resolved graph and the retained evidence
|
||||
//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch
|
||||
//! behaviour.
|
||||
|
||||
/// Return the workspace root above the PdfCraft app crate.
|
||||
fn root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`.
|
||||
fn locked_packages(name: &str) -> Vec<String> {
|
||||
let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock");
|
||||
let needle = format!("name = \"{name}\"");
|
||||
lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something
|
||||
/// to resolve by taking the first match.
|
||||
fn locked(name: &str) -> String {
|
||||
let mut packages = locked_packages(name);
|
||||
assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len());
|
||||
packages.remove(0)
|
||||
}
|
||||
|
||||
/// The resolved package must be the exact canonical storytold git source at the reviewed
|
||||
/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source`
|
||||
/// line) is refused: it would make the graph depend on a developer's checkout. A published path
|
||||
/// patch is refused as well.
|
||||
fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
|
||||
let package = locked(name);
|
||||
let expected_version = format!("version = \"{version}\"");
|
||||
assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}");
|
||||
let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect();
|
||||
assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}");
|
||||
let expected_source = format!("\"git+{url}?rev={rev}#{rev}\"");
|
||||
assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]);
|
||||
|
||||
let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
|
||||
assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch");
|
||||
}
|
||||
|
||||
/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance
|
||||
/// files remain.
|
||||
#[test]
|
||||
fn vendor_provenance_window_stack_graph_is_resolved() {
|
||||
assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc");
|
||||
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a");
|
||||
// Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows.
|
||||
let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md");
|
||||
for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] {
|
||||
assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}");
|
||||
}
|
||||
let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance");
|
||||
assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base");
|
||||
for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] {
|
||||
assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork");
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches.
|
||||
#[test]
|
||||
fn renderer_and_security_vendors_remain() {
|
||||
let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
|
||||
for vendor in [
|
||||
"vendor/hayro",
|
||||
"vendor/hayro-interpret",
|
||||
"vendor/hayro-syntax",
|
||||
"vendor/hayro-cmap",
|
||||
"vendor/hayro-jbig2",
|
||||
"vendor/hayro-postscript",
|
||||
"vendor/lopdf",
|
||||
"vendor/vello_common",
|
||||
] {
|
||||
assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! vendor/winit patch (8) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange`
|
||||
/// unmarked.
|
||||
#[test]
|
||||
fn winit_commits_macos_text_inserted_outside_a_key_press() {
|
||||
fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() {
|
||||
// The macOS emoji picker inserts its text with `insertText` outside any key press and without
|
||||
// a composition. winit 0.30.13 reports `insertText` only as part of a key press or a
|
||||
// composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! vendor/winit patch (6) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the
|
||||
/// marked-text underline.
|
||||
#[test]
|
||||
fn winit_commits_macos_compositions_that_end_without_marked_text() {
|
||||
fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() {
|
||||
// winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input
|
||||
// methods that end a composition with `unmarkText` first lost the committed text, and Apple
|
||||
// Korean, which clears its marked text before inserting the key typed after a syllable (`한`
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (7) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text.
|
||||
#[test]
|
||||
fn winit_discards_the_macos_composition_when_ime_is_disallowed() {
|
||||
fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() {
|
||||
// egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text
|
||||
// but doesn't tell the input method, which keeps the composition and continues it once IME is
|
||||
// allowed again: the abandoned syllable comes back in the next field. vendor/winit calls
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (10) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition.
|
||||
#[test]
|
||||
fn winit_retries_the_first_apple_korean_key_once() {
|
||||
fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() {
|
||||
// On the first key in a freshly started app, Apple Korean can answer with `insertText` and the
|
||||
// raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave
|
||||
// ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (9) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit sends a key committed through macOS IME only once.
|
||||
#[test]
|
||||
fn winit_sends_a_key_committed_through_macos_ime_only_once() {
|
||||
fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() {
|
||||
// Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`,
|
||||
// `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so
|
||||
// the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! vendor/winit patch (12) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still
|
||||
/// moves the window.
|
||||
#[test]
|
||||
fn winit_lets_macos_title_bar_drags_reach_the_app() {
|
||||
fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() {
|
||||
// On macOS the tab strip is the title bar: a transparent title bar over a full-size content
|
||||
// view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit
|
||||
// moved the whole window for a drag that began on a document tab, and tabs could not be
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
|
||||
@@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas.
|
||||
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
let ctx = egui::Context::default();
|
||||
let mut previous = event::TouchPhase::Ended;
|
||||
@@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
|
||||
let input = RawInput {
|
||||
time: Some(frame as f64 / 60.0),
|
||||
events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
|
||||
events: vec![Event::MouseWheel {
|
||||
unit,
|
||||
delta: vec2(0.0, dy),
|
||||
phase: egui_phase(phase),
|
||||
modifiers,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
|
||||
@@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
seen
|
||||
}
|
||||
|
||||
/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of
|
||||
/// zooming.
|
||||
#[test]
|
||||
fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
|
||||
let seen = wheel_trace(source, false);
|
||||
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
|
||||
@@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels.
|
||||
#[test]
|
||||
fn discrete_wheels_still_follow_current_modifiers() {
|
||||
fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() {
|
||||
let seen = wheel_trace(AxisSource::Wheel, true);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
assert_eq!(seen[2].0, 1.0);
|
||||
assert!(seen[2].1.y > 0.0);
|
||||
}
|
||||
|
||||
/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends.
|
||||
#[test]
|
||||
fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
// A finger scroll's momentum may arrive after Ctrl is released.
|
||||
let seen = wheel_trace(AxisSource::Finger, false);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
@@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
|
||||
}
|
||||
|
||||
/// Regression test: wheel sources without a phase keep the existing start, move and end policy.
|
||||
#[test]
|
||||
fn unknown_sources_keep_the_existing_phase_policy() {
|
||||
fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() {
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (2) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed.
|
||||
#[test]
|
||||
fn winit_keeps_the_wayland_text_input_until_the_seat_goes() {
|
||||
fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() {
|
||||
// winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a
|
||||
// touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when
|
||||
// a capability is added, so IME stops working in a running PdfCraft until it is restarted.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (4) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown.
|
||||
#[test]
|
||||
fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
|
||||
fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
|
||||
// eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the
|
||||
// window once its first frame is drawn. winit 0.30.13 applies the maximized state with
|
||||
// `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (3) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it.
|
||||
#[test]
|
||||
fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
|
||||
fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
|
||||
// winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether
|
||||
// or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so
|
||||
// the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not
|
||||
/// override scaling.
|
||||
#[test]
|
||||
fn winit_carries_the_x11_server_dpi_fallback() {
|
||||
fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() {
|
||||
// The behavior tests live beside the policy in winit (util::randr::tests).
|
||||
// This guards PdfCraft's vendored copy, which workspace tests do not test itself.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (5) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit focuses the X11 input context it replaces.
|
||||
#[test]
|
||||
fn winit_focuses_the_x11_input_context_it_replaces() {
|
||||
fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() {
|
||||
// egui allows IME when a text field gains focus and disallows it when the field loses focus.
|
||||
// On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the
|
||||
// new one to the next focus event, which never comes while PdfCraft's window keeps the focus:
|
||||
|
||||
+21
-21
@@ -14,27 +14,27 @@
|
||||
(highlight, sticky note, stamp, ...), so annotation geometry follows the layout.
|
||||
-->
|
||||
<style>
|
||||
@font-face { font-display: block; font-family: "PC Mono"; src: url("{{ASSETS}}/fonts/JetBrainsMono-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Inter"; src: url("{{FONTS}}/Inter[opsz,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Inter"; src: url("{{FONTS}}/Inter-Italic[opsz,wght].ttf"); font-weight: 100 900; font-style: italic; }
|
||||
@font-face { font-display: block; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond[wght].ttf"); font-weight: 400 800; }
|
||||
@font-face { font-display: block; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond-Italic[wght].ttf"); font-weight: 400 800; font-style: italic; }
|
||||
@font-face { font-display: block; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay[wght].ttf"); font-weight: 400 900; }
|
||||
@font-face { font-display: block; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay-Italic[wght].ttf"); font-weight: 400 900; font-style: italic; }
|
||||
@font-face { font-display: block; font-family: "PC Vibes"; src: url("{{FONTS}}/GreatVibes-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Math"; src: url("{{FONTS}}/STIXTwoMath-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Amiri"; src: url("{{FONTS}}/Amiri-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Hebrew"; src: url("{{FONTS}}/NotoSansHebrew[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Devanagari"; src: url("{{FONTS}}/NotoSansDevanagari[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Thai"; src: url("{{FONTS}}/NotoSansThai[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Armenian"; src: url("{{FONTS}}/NotoSansArmenian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Georgian"; src: url("{{FONTS}}/NotoSansGeorgian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Tamil"; src: url("{{FONTS}}/NotoSansTamil[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: block; font-family: "PC Charis"; src: url("{{FONTS}}/CharisSIL-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC XiaoWei"; src: url("{{FONTS}}/ZCOOLXiaoWei-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Mincho"; src: url("{{FONTS}}/ShipporiMincho-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Gowun"; src: url("{{FONTS}}/GowunBatang-Regular.ttf"); }
|
||||
@font-face { font-display: block; font-family: "PC Emoji"; src: url("{{FONTS}}/NotoColorEmoji-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Mono"; src: url("{{ASSETS}}/fonts/JetBrainsMono-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Inter"; src: url("{{FONTS}}/Inter[opsz,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Inter"; src: url("{{FONTS}}/Inter-Italic[opsz,wght].ttf"); font-weight: 100 900; font-style: italic; }
|
||||
@font-face { font-display: swap; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond[wght].ttf"); font-weight: 400 800; }
|
||||
@font-face { font-display: swap; font-family: "PC Garamond"; src: url("{{FONTS}}/EBGaramond-Italic[wght].ttf"); font-weight: 400 800; font-style: italic; }
|
||||
@font-face { font-display: swap; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay[wght].ttf"); font-weight: 400 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Playfair"; src: url("{{FONTS}}/PlayfairDisplay-Italic[wght].ttf"); font-weight: 400 900; font-style: italic; }
|
||||
@font-face { font-display: swap; font-family: "PC Vibes"; src: url("{{FONTS}}/GreatVibes-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Math"; src: url("{{FONTS}}/STIXTwoMath-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Amiri"; src: url("{{FONTS}}/Amiri-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Hebrew"; src: url("{{FONTS}}/NotoSansHebrew[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Devanagari"; src: url("{{FONTS}}/NotoSansDevanagari[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Thai"; src: url("{{FONTS}}/NotoSansThai[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Armenian"; src: url("{{FONTS}}/NotoSansArmenian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Georgian"; src: url("{{FONTS}}/NotoSansGeorgian[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Tamil"; src: url("{{FONTS}}/NotoSansTamil[wdth,wght].ttf"); font-weight: 100 900; }
|
||||
@font-face { font-display: swap; font-family: "PC Charis"; src: url("{{FONTS}}/CharisSIL-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC XiaoWei"; src: url("{{FONTS}}/ZCOOLXiaoWei-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Mincho"; src: url("{{FONTS}}/ShipporiMincho-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Gowun"; src: url("{{FONTS}}/GowunBatang-Regular.ttf"); }
|
||||
@font-face { font-display: swap; font-family: "PC Emoji"; src: url("{{FONTS}}/NotoColorEmoji-Regular.ttf"); }
|
||||
|
||||
@page { size: Letter; margin: 0; }
|
||||
|
||||
|
||||
@@ -13,3 +13,7 @@ disallowed-methods = [
|
||||
{ path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
|
||||
{ path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
|
||||
]
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
@@ -176,3 +176,7 @@ real_name = "Nick Nyssen"
|
||||
|
||||
[people.agent-reed]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
+20
-14
@@ -211,6 +211,25 @@ fn close(marks: &mut Vec<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f6
|
||||
draw(marks, b);
|
||||
}
|
||||
|
||||
/// The page-space box a `Do` operator paints: a form's transformed `/BBox`, or the unit square for an image.
|
||||
/// `None` when the operand is not a resolvable XObject or the form's box is malformed.
|
||||
fn xobject_bbox(doc: &Document, xobjects: &Dict, op: &pdfcraft_content::Op, top: &Matrix) -> Option<[f64; 4]> {
|
||||
let r = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)?;
|
||||
Some(match &*doc.get(r) {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
|
||||
let bb: Vec<f64> =
|
||||
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
|
||||
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
|
||||
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
match bb[..] {
|
||||
[a, b, c, d] => m.then(top).bbox([a, b, c, d]),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass
|
||||
/// over the page. What a sequence draws counts for every sequence of `ids` around it too.
|
||||
pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> HashMap<i64, [f64; 4]> {
|
||||
@@ -274,20 +293,7 @@ pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) ->
|
||||
path.clear();
|
||||
}
|
||||
b"Do" if inside => {
|
||||
let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue };
|
||||
let unit = match &*doc.get(r) {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => {
|
||||
let bb: Vec<f64> =
|
||||
s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default();
|
||||
let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[]))
|
||||
.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
match bb[..] {
|
||||
[a, b, c, d] => m.then(&top).bbox([a, b, c, d]),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
};
|
||||
let Some(unit) = xobject_bbox(doc, &xobjects, &op, &top) else { continue };
|
||||
draw(&mut marks, unit);
|
||||
}
|
||||
b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
|
||||
|
||||
+33
-27
@@ -132,6 +132,38 @@ fn harmless_inside(op: &[u8]) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged.
|
||||
fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagError> {
|
||||
let mut start = None;
|
||||
let mut has_text = false;
|
||||
let mut spans = Vec::new();
|
||||
for (at, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"BT" if start.is_none() => {
|
||||
start = Some(at);
|
||||
has_text = false;
|
||||
}
|
||||
b"ET" => {
|
||||
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
|
||||
if has_text {
|
||||
spans.push((first, at));
|
||||
if spans.len() > MAX_TEXT_OBJECTS {
|
||||
return Err(unsupported(index, "too many text objects (maximum 4096)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
|
||||
name if start.is_some() && harmless_inside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => {}
|
||||
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
|
||||
}
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(index, "unterminated BT text object"));
|
||||
}
|
||||
Ok(spans)
|
||||
}
|
||||
|
||||
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
let root = doc.root().ok_or(AutoTagError::NoCatalog)?;
|
||||
let catalog = doc.get(root);
|
||||
@@ -163,33 +195,7 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
|
||||
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
|
||||
return Err(unsupported(index, "content has malformed or too many operators"));
|
||||
}
|
||||
let mut start = None;
|
||||
let mut has_text = false;
|
||||
let mut spans = Vec::new();
|
||||
for (at, op) in parsed.ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"BT" if start.is_none() => {
|
||||
start = Some(at);
|
||||
has_text = false;
|
||||
}
|
||||
b"ET" => {
|
||||
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
|
||||
if has_text {
|
||||
spans.push((first, at));
|
||||
if spans.len() > MAX_TEXT_OBJECTS {
|
||||
return Err(unsupported(index, "too many text objects (maximum 4096)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
|
||||
name if start.is_some() && harmless_inside(name) => {}
|
||||
name if start.is_none() && harmless_outside(name) => {}
|
||||
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
|
||||
}
|
||||
}
|
||||
if start.is_some() {
|
||||
return Err(unsupported(index, "unterminated BT text object"));
|
||||
}
|
||||
let spans = text_spans(&parsed.ops, index)?;
|
||||
total = total.saturating_add(spans.len());
|
||||
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
|
||||
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`).
|
||||
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
|
||||
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
|
||||
A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as
|
||||
ISO 32000-2 Table 178 places them, the caption in black Helvetica 10.
|
||||
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
|
||||
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
|
||||
draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line;
|
||||
`appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
|
||||
draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic
|
||||
stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
|
||||
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
|
||||
flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction
|
||||
mark's overlay text, which becomes page content when applied; and a restyle or resize of a
|
||||
|
||||
+29
-353
@@ -16,6 +16,11 @@ pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
|
||||
|
||||
use crate::{NOTE_SIZE, Rgb, n};
|
||||
|
||||
mod symbols;
|
||||
|
||||
pub(crate) use symbols::display_stamp;
|
||||
use symbols::{CAPTION_SIZE, attach_icon, ellipse, line_caption, line_layout, note_icon, stamp};
|
||||
|
||||
/// Length of an arrowhead for a line of width `w`.
|
||||
pub fn arrow_size(w: f64) -> f64 {
|
||||
6.0 + 3.0 * w.max(0.0)
|
||||
@@ -302,13 +307,14 @@ fn text_turn(d: &Dict) -> i64 {
|
||||
|
||||
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
|
||||
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
|
||||
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
|
||||
/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
|
||||
/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a
|
||||
/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
|
||||
/// it can be drawn.
|
||||
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
|
||||
let (what, text) = match d.name(b"Subtype")? {
|
||||
b"FreeText" => ("the comment's text", text(b"Contents")?),
|
||||
b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?),
|
||||
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
|
||||
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
|
||||
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
|
||||
@@ -389,6 +395,8 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let w = border_width(d);
|
||||
let mut res = Dict::new();
|
||||
let mut c = String::new();
|
||||
// Content after `c` that isn't UTF-8 (WinAnsi text).
|
||||
let mut tail: Vec<u8> = Vec::new();
|
||||
let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly");
|
||||
if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
@@ -513,13 +521,29 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
return None;
|
||||
}
|
||||
let fill = interior_color(d, Some(col)).unwrap_or(col);
|
||||
let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w);
|
||||
let [p, q] = layout.line;
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
|
||||
c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3])));
|
||||
for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) {
|
||||
for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) {
|
||||
c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1)));
|
||||
}
|
||||
c.push_str("[] 0 d\n");
|
||||
for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) {
|
||||
if !line_end(&mut c, end, tip, from, w) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some((text, m)) = layout.caption {
|
||||
// The caption is black, in the standard font the measurement label uses.
|
||||
tail.extend(
|
||||
format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5]))
|
||||
.bytes(),
|
||||
);
|
||||
// WinAnsi bytes go into the stream as-is, not through UTF-8.
|
||||
tail.extend(literal(&win_ansi(&text)));
|
||||
tail.extend_from_slice(b" Tj ET Q\n");
|
||||
res.set(b"Font".to_vec(), helvetica_resource());
|
||||
}
|
||||
}
|
||||
b"Polygon" | b"PolyLine" => {
|
||||
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
|
||||
@@ -866,6 +890,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// PdfCraft measurement captions are kept separate from the comment's free-form text.
|
||||
// A restyle regenerates the path and its value together.
|
||||
let mut out = c.into_bytes();
|
||||
out.extend(tail);
|
||||
if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) {
|
||||
let value = value.to_text();
|
||||
if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") {
|
||||
@@ -882,352 +907,3 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
}
|
||||
Some(form(rect, &out, res))
|
||||
}
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of
|
||||
/// their own and the palette's colour for their meaning.
|
||||
fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> {
|
||||
use crate::StampKind::*;
|
||||
let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease];
|
||||
if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) {
|
||||
return Some((kind.label(), kind.color()));
|
||||
}
|
||||
let (label, like) = match name {
|
||||
b"Experimental" => ("EXPERIMENTAL", Draft),
|
||||
b"AsIs" => ("AS IS", Draft),
|
||||
b"Expired" => ("EXPIRED", NotApproved),
|
||||
b"Sold" => ("SOLD", Approved),
|
||||
b"Departmental" => ("DEPARTMENTAL", Draft),
|
||||
b"TopSecret" => ("TOP SECRET", Confidential),
|
||||
_ => return None,
|
||||
};
|
||||
Some((label, like.color()))
|
||||
}
|
||||
|
||||
/// The neutral grey of a stamp whose name has no drawing.
|
||||
const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45];
|
||||
|
||||
/// The display appearance of a rubber stamp another program wrote without one: the stamp its
|
||||
/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the
|
||||
/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays
|
||||
/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp
|
||||
/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text.
|
||||
///
|
||||
/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so
|
||||
/// edits (restyling, flattening) never write this drawing into the file. `None` for any other
|
||||
/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is
|
||||
/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal
|
||||
/// appearance), or a `/Rect` that isn't finite or is under a point wide or high.
|
||||
pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline");
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") {
|
||||
return None;
|
||||
}
|
||||
let rect = nums(d, b"Rect")?;
|
||||
let [a, b, c, e] = rect.as_slice() else { return None };
|
||||
let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)];
|
||||
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp");
|
||||
let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice()));
|
||||
if let Some((label, col)) = name.and_then(standard_stamp) {
|
||||
return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res));
|
||||
}
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5);
|
||||
let mut c = String::new();
|
||||
if opacity < 1.0 {
|
||||
c.push_str("/GS0 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n",
|
||||
// Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour.
|
||||
rg_stroke(PLACEHOLDER_GREY),
|
||||
n(a0),
|
||||
n(b0),
|
||||
n(a1 - a0),
|
||||
n(b1 - b0),
|
||||
n(a0),
|
||||
n(b0),
|
||||
n(a1),
|
||||
n(b1),
|
||||
n(a0),
|
||||
n(b1),
|
||||
n(a1),
|
||||
n(b0)
|
||||
));
|
||||
Some(form(rect, c.as_bytes(), res))
|
||||
}
|
||||
|
||||
/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold
|
||||
/// capitals, and the dynamic stamps' "By … at …" line.
|
||||
fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let h = y1 - y0;
|
||||
let mut c = String::new();
|
||||
if opacity < 1.0 {
|
||||
c.push_str("/GS0 gs\n");
|
||||
}
|
||||
let lw = (h * 0.07).clamp(1.0, 3.0);
|
||||
let inset = lw / 2.0 + 0.5;
|
||||
if sign_here {
|
||||
// A tag pointing left, filled, with white text.
|
||||
let tip = h * 0.45;
|
||||
c.push_str(&format!(
|
||||
"{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n",
|
||||
rg(col),
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + inset),
|
||||
n(y0 + h / 2.0),
|
||||
n(x0 + tip),
|
||||
n(y1 - inset),
|
||||
n(x1 - inset),
|
||||
n(y1 - inset),
|
||||
n(x1 - inset),
|
||||
n(y0 + inset),
|
||||
n(x0 + tip),
|
||||
n(y0 + inset)
|
||||
));
|
||||
} else {
|
||||
// A rounded frame with a light tint.
|
||||
let r = (h * 0.2).min(8.0);
|
||||
let k = 0.552_284_75 * r;
|
||||
let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset);
|
||||
let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12);
|
||||
c.push_str(&format!(
|
||||
"{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n",
|
||||
rg(tint),
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(a0 + r),
|
||||
n(b0),
|
||||
n(a1 - r),
|
||||
n(b0),
|
||||
n(a1 - r + k),
|
||||
n(b0),
|
||||
n(a1),
|
||||
n(b0 + r - k),
|
||||
n(a1),
|
||||
n(b0 + r),
|
||||
n(a1),
|
||||
n(b1 - r),
|
||||
n(a1),
|
||||
n(b1 - r + k),
|
||||
n(a1 - r + k),
|
||||
n(b1),
|
||||
n(a1 - r),
|
||||
n(b1),
|
||||
n(a0 + r),
|
||||
n(b1),
|
||||
n(a0 + r - k),
|
||||
n(b1),
|
||||
n(a0),
|
||||
n(b1 - r + k),
|
||||
n(a0),
|
||||
n(b1 - r),
|
||||
n(a0),
|
||||
n(b0 + r),
|
||||
n(a0),
|
||||
n(b0 + r - k),
|
||||
n(a0 + r - k),
|
||||
n(b0),
|
||||
n(a0 + r),
|
||||
n(b0)
|
||||
));
|
||||
}
|
||||
let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) };
|
||||
let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) };
|
||||
let mut size = title_h;
|
||||
let tw = text_width(label, size) * 1.12;
|
||||
if tw > text_area.1 - text_area.0 - 4.0 {
|
||||
size *= (text_area.1 - text_area.0 - 4.0) / tw;
|
||||
}
|
||||
let tw = text_width(label, size) * 1.12;
|
||||
let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col };
|
||||
let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 };
|
||||
let mut out = c.into_bytes();
|
||||
out.extend(
|
||||
format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty))
|
||||
.bytes(),
|
||||
);
|
||||
out.extend(literal(&win_ansi(label)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
if let Some(b) = by {
|
||||
let mut s = by_h;
|
||||
let bw = text_width(b, s);
|
||||
if bw > text_area.1 - text_area.0 - 4.0 {
|
||||
s *= (text_area.1 - text_area.0 - 4.0) / bw;
|
||||
}
|
||||
let bw = text_width(b, s);
|
||||
out.extend(
|
||||
format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(),
|
||||
);
|
||||
out.extend(literal(&win_ansi(b)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
}
|
||||
out.extend_from_slice(b"ET\n");
|
||||
let font = |base: &str| {
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
Object::Dict(f)
|
||||
};
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold"));
|
||||
fonts.set(b"Helv".to_vec(), font("Helvetica"));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
form(rect, &out, res)
|
||||
}
|
||||
|
||||
/// An ellipse inscribed in a rectangle, as four Bézier arcs.
|
||||
fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
|
||||
let k = 0.552_284_75;
|
||||
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0);
|
||||
let (ox, oy) = (rx * k, ry * k);
|
||||
let mut s = format!("{} {} m\n", n(cx + rx), n(cy));
|
||||
for [a, b, c, d, e, f] in [
|
||||
[cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry],
|
||||
[cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy],
|
||||
[cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry],
|
||||
[cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy],
|
||||
] {
|
||||
s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f)));
|
||||
}
|
||||
s.push_str("h\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page
|
||||
/// with a folded corner for everything else, both filled with the note colour.
|
||||
/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings.
|
||||
fn attach_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col));
|
||||
match name {
|
||||
"Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"),
|
||||
"Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"),
|
||||
"Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"),
|
||||
_ => {
|
||||
// Push pin: a round head on a short needle.
|
||||
s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A closed circle path (four Bézier quarters) centred on (`x`, `y`).
|
||||
fn circle_path(x: f64, y: f64, r: f64) -> String {
|
||||
let k = r * 0.5523;
|
||||
let p = |v: f64| n(v);
|
||||
format!(
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n",
|
||||
p(x + r),
|
||||
p(y),
|
||||
p(x + r),
|
||||
p(y + k),
|
||||
p(x + k),
|
||||
p(y + r),
|
||||
p(x),
|
||||
p(y + r),
|
||||
p(x - k),
|
||||
p(y + r),
|
||||
p(x - r),
|
||||
p(y + k),
|
||||
p(x - r),
|
||||
p(y),
|
||||
p(x - r),
|
||||
p(y - k),
|
||||
p(x - k),
|
||||
p(y - r),
|
||||
p(x),
|
||||
p(y - r),
|
||||
p(x + k),
|
||||
p(y - r),
|
||||
p(x + r),
|
||||
p(y - k),
|
||||
p(x + r),
|
||||
p(y)
|
||||
)
|
||||
}
|
||||
|
||||
/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of
|
||||
/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO
|
||||
/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`.
|
||||
fn note_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col));
|
||||
match name {
|
||||
"Comment" => {
|
||||
s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n");
|
||||
s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n");
|
||||
}
|
||||
// A key lying flat: a round bow with a hole, a shaft and two bits.
|
||||
"Key" => {
|
||||
s.push_str(&circle_path(5.5, 10.0, 4.5));
|
||||
s.push_str(&circle_path(5.0, 10.0, 1.5));
|
||||
s.push_str(
|
||||
"9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n",
|
||||
);
|
||||
}
|
||||
// A disc with a question mark.
|
||||
"Help" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 8.5));
|
||||
s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n");
|
||||
}
|
||||
// An insertion caret.
|
||||
"Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"),
|
||||
// A caret over a bar: a new paragraph goes here.
|
||||
"NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"),
|
||||
// A pilcrow: a filled bowl and two stems.
|
||||
"Paragraph" => {
|
||||
s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n");
|
||||
s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n");
|
||||
}
|
||||
// A tick.
|
||||
"Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"),
|
||||
// A ring.
|
||||
"Circle" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 8.5));
|
||||
s.push_str(&circle_path(10.0, 10.0, 5.0));
|
||||
s.push_str("B*\n");
|
||||
}
|
||||
// A thick X.
|
||||
"Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"),
|
||||
// A ring with sighting lines through it.
|
||||
"CrossHairs" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 7.0));
|
||||
s.push_str(&circle_path(10.0, 10.0, 4.5));
|
||||
s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n");
|
||||
}
|
||||
// A five-pointed star.
|
||||
"Star" => {
|
||||
let points: Vec<String> = (0..10)
|
||||
.map(|i| {
|
||||
let r = if i % 2 == 0 { 9.5 } else { 3.9 };
|
||||
let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0;
|
||||
format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin()))
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l ")));
|
||||
}
|
||||
// Block arrows.
|
||||
"RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"),
|
||||
"UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"),
|
||||
_ => {
|
||||
s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n");
|
||||
s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
use super::*;
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of
|
||||
/// their own and the palette's colour for their meaning.
|
||||
fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> {
|
||||
use crate::StampKind::*;
|
||||
let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease];
|
||||
if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) {
|
||||
return Some((kind.label(), kind.color()));
|
||||
}
|
||||
let (label, like) = match name {
|
||||
b"Experimental" => ("EXPERIMENTAL", Draft),
|
||||
b"AsIs" => ("AS IS", Draft),
|
||||
b"Expired" => ("EXPIRED", NotApproved),
|
||||
b"Sold" => ("SOLD", Approved),
|
||||
b"Departmental" => ("DEPARTMENTAL", Draft),
|
||||
b"TopSecret" => ("TOP SECRET", Confidential),
|
||||
_ => return None,
|
||||
};
|
||||
Some((label, like.color()))
|
||||
}
|
||||
|
||||
/// The neutral grey of a stamp whose name has no drawing.
|
||||
const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45];
|
||||
|
||||
/// The display appearance of a rubber stamp another program wrote without one: the stamp its
|
||||
/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the
|
||||
/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays
|
||||
/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp
|
||||
/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text.
|
||||
///
|
||||
/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so
|
||||
/// edits (restyling, flattening) never write this drawing into the file. `None` for any other
|
||||
/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is
|
||||
/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal
|
||||
/// appearance), or a `/Rect` that isn't finite or is under a point wide or high.
|
||||
pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline");
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") {
|
||||
return None;
|
||||
}
|
||||
let rect = nums(d, b"Rect")?;
|
||||
let [a, b, c, e] = rect.as_slice() else { return None };
|
||||
let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)];
|
||||
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp");
|
||||
let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice()));
|
||||
if let Some((label, col)) = name.and_then(standard_stamp) {
|
||||
return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res));
|
||||
}
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5);
|
||||
let mut c = String::new();
|
||||
if opacity < 1.0 {
|
||||
c.push_str("/GS0 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n",
|
||||
// Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour.
|
||||
rg_stroke(PLACEHOLDER_GREY),
|
||||
n(a0),
|
||||
n(b0),
|
||||
n(a1 - a0),
|
||||
n(b1 - b0),
|
||||
n(a0),
|
||||
n(b0),
|
||||
n(a1),
|
||||
n(b1),
|
||||
n(a0),
|
||||
n(b1),
|
||||
n(a1),
|
||||
n(b0)
|
||||
));
|
||||
Some(form(rect, c.as_bytes(), res))
|
||||
}
|
||||
|
||||
/// Size of a line's caption, as of a measurement's label.
|
||||
pub(super) const CAPTION_SIZE: f64 = 10.0;
|
||||
|
||||
/// Characters of a line's caption drawn at most, as for a measurement's label.
|
||||
const MAX_CAPTION_CHARS: usize = 256;
|
||||
|
||||
/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches,
|
||||
/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow.
|
||||
const MAX_LINE_EXTENT: f64 = 14_400.0;
|
||||
|
||||
/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one.
|
||||
fn line_length(v: Option<f64>) -> f64 {
|
||||
v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT))
|
||||
}
|
||||
|
||||
/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the
|
||||
/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a
|
||||
/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption.
|
||||
pub(super) fn line_caption(d: &Dict) -> Option<String> {
|
||||
if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") {
|
||||
return None;
|
||||
}
|
||||
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
|
||||
let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?;
|
||||
let words: Vec<&str> = raw.split_whitespace().collect();
|
||||
let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect();
|
||||
(!caption.is_empty()).then_some(caption)
|
||||
}
|
||||
|
||||
/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and
|
||||
/// numeric character references read, block ends read as spaces.
|
||||
fn rich_plain(rc: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut tag: Option<String> = None;
|
||||
// Rich text past this is not caption material; it bounds the work.
|
||||
for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) {
|
||||
match (&mut tag, ch) {
|
||||
(None, '<') => tag = Some(String::new()),
|
||||
(Some(name), '>') => {
|
||||
let name =
|
||||
name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase();
|
||||
if matches!(name.as_str(), "p" | "br" | "div" | "li") {
|
||||
out.push(' ');
|
||||
}
|
||||
tag = None;
|
||||
}
|
||||
(Some(name), c) => {
|
||||
if name.len() < 16 {
|
||||
name.push(c);
|
||||
}
|
||||
}
|
||||
(None, c) => out.push(c),
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
let mut rest = out.as_str();
|
||||
while let Some(at) = rest.find('&') {
|
||||
text.push_str(rest.get(..at).unwrap_or_default());
|
||||
let after = rest.get(at + 1..).unwrap_or_default();
|
||||
let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end)));
|
||||
let decoded = entity.and_then(|(name, end)| {
|
||||
let c = match name {
|
||||
"amp" => '&',
|
||||
"lt" => '<',
|
||||
"gt" => '>',
|
||||
"quot" => '"',
|
||||
"apos" => '\'',
|
||||
_ => {
|
||||
let num = name.strip_prefix('#')?;
|
||||
let code = match num.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
||||
None => num.parse().ok()?,
|
||||
};
|
||||
char::from_u32(code)?
|
||||
}
|
||||
};
|
||||
Some((c, end))
|
||||
});
|
||||
match decoded {
|
||||
Some((c, end)) => {
|
||||
text.push(c);
|
||||
rest = after.get(end + 1..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
text.push('&');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
text.push_str(rest);
|
||||
text
|
||||
}
|
||||
|
||||
/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178).
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct LineLayout {
|
||||
/// The line itself, start to end: `/L`, or `/L` moved out by its leader lines.
|
||||
pub(super) line: [(f64, f64); 2],
|
||||
/// Strokes along the line: the whole line, or the two pieces either side of an inline caption.
|
||||
pub(super) strokes: Vec<[(f64, f64); 2]>,
|
||||
/// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`.
|
||||
pub(super) leaders: Vec<[(f64, f64); 2]>,
|
||||
/// A short stroke from the line to a caption `/CO` moved off it.
|
||||
pub(super) tick: Option<[(f64, f64); 2]>,
|
||||
/// The caption and its text matrix.
|
||||
pub(super) caption: Option<(String, [f64; 6])>,
|
||||
}
|
||||
|
||||
/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption
|
||||
/// entries.
|
||||
///
|
||||
/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L`
|
||||
/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn
|
||||
/// clockwise from the direction of travel; a negative one puts it on the right. The line sits
|
||||
/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0.
|
||||
///
|
||||
/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to
|
||||
/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line,
|
||||
/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h`
|
||||
/// along the line and `v` across it (positive: up in reading direction); a caption moved off an
|
||||
/// inline line gets a tick from the line to it.
|
||||
///
|
||||
/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption.
|
||||
/// Every point is finite; if anything derived from the entries is not, they are left out.
|
||||
pub(super) fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout {
|
||||
let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() };
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let len = dx.hypot(dy);
|
||||
if !len.is_finite() || len <= 0.0 {
|
||||
return plain;
|
||||
}
|
||||
let (ux, uy) = (dx / len, dy / len);
|
||||
let mut out = LineLayout { line: [a, b], ..LineLayout::default() };
|
||||
let ll = line_length(d.get(b"LL").and_then(Object::as_f64));
|
||||
if ll != 0.0 {
|
||||
// The unit normal from `/L` towards the line: left of travel for a positive /LL.
|
||||
let s = ll.signum();
|
||||
let (nx, ny) = (-uy * s, ux * s);
|
||||
let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0);
|
||||
let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0);
|
||||
let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k);
|
||||
let off = ll.abs() + llo;
|
||||
out.line = [at(a, off), at(b, off)];
|
||||
out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]];
|
||||
}
|
||||
let [p, _] = out.line;
|
||||
let along = |t: f64| (p.0 + ux * t, p.1 + uy * t);
|
||||
let mut gap = None;
|
||||
if let Some(text) = line_caption(d) {
|
||||
let size = CAPTION_SIZE;
|
||||
let width = text_width(&text, size);
|
||||
// Reading direction, and "up" across it.
|
||||
let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) };
|
||||
let (vx, vy) = (-ty, tx);
|
||||
let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default();
|
||||
let h = line_length(co.first().copied());
|
||||
let v = line_length(co.get(1).copied());
|
||||
let top = d.name(b"CP") == Some(b"Top");
|
||||
// The caption's centre, as a distance along the line from `p`.
|
||||
let centre = len / 2.0 + h * (tx * ux + ty * uy);
|
||||
let mid = along(centre);
|
||||
// Baseline across the line: on top of it, or with the text centred on it.
|
||||
let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 };
|
||||
let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base);
|
||||
out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1]));
|
||||
if !top && v.abs() < size / 2.0 {
|
||||
let half = width / 2.0 + 2.0;
|
||||
gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len)));
|
||||
} else if !top {
|
||||
let reach = v - v.signum() * size * 0.4;
|
||||
out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]);
|
||||
}
|
||||
}
|
||||
out.strokes = match gap {
|
||||
Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(),
|
||||
None => vec![out.line],
|
||||
};
|
||||
let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite();
|
||||
let all_finite =
|
||||
out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite)
|
||||
&& out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite()));
|
||||
if all_finite { out } else { plain }
|
||||
}
|
||||
|
||||
/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold
|
||||
/// capitals, and the dynamic stamps' "By … at …" line.
|
||||
pub(super) fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let h = y1 - y0;
|
||||
let mut c = String::new();
|
||||
if opacity < 1.0 {
|
||||
c.push_str("/GS0 gs\n");
|
||||
}
|
||||
let lw = (h * 0.07).clamp(1.0, 3.0);
|
||||
let inset = lw / 2.0 + 0.5;
|
||||
if sign_here {
|
||||
// A tag pointing left, filled, with white text.
|
||||
let tip = h * 0.45;
|
||||
c.push_str(&format!(
|
||||
"{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n",
|
||||
rg(col),
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + inset),
|
||||
n(y0 + h / 2.0),
|
||||
n(x0 + tip),
|
||||
n(y1 - inset),
|
||||
n(x1 - inset),
|
||||
n(y1 - inset),
|
||||
n(x1 - inset),
|
||||
n(y0 + inset),
|
||||
n(x0 + tip),
|
||||
n(y0 + inset)
|
||||
));
|
||||
} else {
|
||||
// A rounded frame with a light tint.
|
||||
let r = (h * 0.2).min(8.0);
|
||||
let k = 0.552_284_75 * r;
|
||||
let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset);
|
||||
let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12);
|
||||
c.push_str(&format!(
|
||||
"{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n",
|
||||
rg(tint),
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(a0 + r),
|
||||
n(b0),
|
||||
n(a1 - r),
|
||||
n(b0),
|
||||
n(a1 - r + k),
|
||||
n(b0),
|
||||
n(a1),
|
||||
n(b0 + r - k),
|
||||
n(a1),
|
||||
n(b0 + r),
|
||||
n(a1),
|
||||
n(b1 - r),
|
||||
n(a1),
|
||||
n(b1 - r + k),
|
||||
n(a1 - r + k),
|
||||
n(b1),
|
||||
n(a1 - r),
|
||||
n(b1),
|
||||
n(a0 + r),
|
||||
n(b1),
|
||||
n(a0 + r - k),
|
||||
n(b1),
|
||||
n(a0),
|
||||
n(b1 - r + k),
|
||||
n(a0),
|
||||
n(b1 - r),
|
||||
n(a0),
|
||||
n(b0 + r),
|
||||
n(a0),
|
||||
n(b0 + r - k),
|
||||
n(a0 + r - k),
|
||||
n(b0),
|
||||
n(a0 + r),
|
||||
n(b0)
|
||||
));
|
||||
}
|
||||
let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) };
|
||||
let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) };
|
||||
let mut size = title_h;
|
||||
let tw = text_width(label, size) * 1.12;
|
||||
if tw > text_area.1 - text_area.0 - 4.0 {
|
||||
size *= (text_area.1 - text_area.0 - 4.0) / tw;
|
||||
}
|
||||
let tw = text_width(label, size) * 1.12;
|
||||
let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col };
|
||||
let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 };
|
||||
let mut out = c.into_bytes();
|
||||
out.extend(
|
||||
format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty))
|
||||
.bytes(),
|
||||
);
|
||||
out.extend(literal(&win_ansi(label)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
if let Some(b) = by {
|
||||
let mut s = by_h;
|
||||
let bw = text_width(b, s);
|
||||
if bw > text_area.1 - text_area.0 - 4.0 {
|
||||
s *= (text_area.1 - text_area.0 - 4.0) / bw;
|
||||
}
|
||||
let bw = text_width(b, s);
|
||||
out.extend(
|
||||
format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(),
|
||||
);
|
||||
out.extend(literal(&win_ansi(b)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
}
|
||||
out.extend_from_slice(b"ET\n");
|
||||
let font = |base: &str| {
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
Object::Dict(f)
|
||||
};
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold"));
|
||||
fonts.set(b"Helv".to_vec(), font("Helvetica"));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
form(rect, &out, res)
|
||||
}
|
||||
|
||||
/// An ellipse inscribed in a rectangle, as four Bézier arcs.
|
||||
pub(super) fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
|
||||
let k = 0.552_284_75;
|
||||
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0);
|
||||
let (ox, oy) = (rx * k, ry * k);
|
||||
let mut s = format!("{} {} m\n", n(cx + rx), n(cy));
|
||||
for [a, b, c, d, e, f] in [
|
||||
[cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry],
|
||||
[cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy],
|
||||
[cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry],
|
||||
[cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy],
|
||||
] {
|
||||
s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f)));
|
||||
}
|
||||
s.push_str("h\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page
|
||||
/// with a folded corner for everything else, both filled with the note colour.
|
||||
/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings.
|
||||
pub(super) fn attach_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col));
|
||||
match name {
|
||||
"Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"),
|
||||
"Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"),
|
||||
"Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"),
|
||||
_ => {
|
||||
// Push pin: a round head on a short needle.
|
||||
s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A closed circle path (four Bézier quarters) centred on (`x`, `y`).
|
||||
fn circle_path(x: f64, y: f64, r: f64) -> String {
|
||||
let k = r * 0.5523;
|
||||
let p = |v: f64| n(v);
|
||||
format!(
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n",
|
||||
p(x + r),
|
||||
p(y),
|
||||
p(x + r),
|
||||
p(y + k),
|
||||
p(x + k),
|
||||
p(y + r),
|
||||
p(x),
|
||||
p(y + r),
|
||||
p(x - k),
|
||||
p(y + r),
|
||||
p(x - r),
|
||||
p(y + k),
|
||||
p(x - r),
|
||||
p(y),
|
||||
p(x - r),
|
||||
p(y - k),
|
||||
p(x - k),
|
||||
p(y - r),
|
||||
p(x),
|
||||
p(y - r),
|
||||
p(x + k),
|
||||
p(y - r),
|
||||
p(x + r),
|
||||
p(y - k),
|
||||
p(x + r),
|
||||
p(y)
|
||||
)
|
||||
}
|
||||
|
||||
/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of
|
||||
/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO
|
||||
/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`.
|
||||
pub(super) fn note_icon(name: &str, col: Rgb) -> String {
|
||||
let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col));
|
||||
match name {
|
||||
"Comment" => {
|
||||
s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n");
|
||||
s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n");
|
||||
}
|
||||
// A key lying flat: a round bow with a hole, a shaft and two bits.
|
||||
"Key" => {
|
||||
s.push_str(&circle_path(5.5, 10.0, 4.5));
|
||||
s.push_str(&circle_path(5.0, 10.0, 1.5));
|
||||
s.push_str(
|
||||
"9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n",
|
||||
);
|
||||
}
|
||||
// A disc with a question mark.
|
||||
"Help" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 8.5));
|
||||
s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n");
|
||||
}
|
||||
// An insertion caret.
|
||||
"Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"),
|
||||
// A caret over a bar: a new paragraph goes here.
|
||||
"NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"),
|
||||
// A pilcrow: a filled bowl and two stems.
|
||||
"Paragraph" => {
|
||||
s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n");
|
||||
s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n");
|
||||
}
|
||||
// A tick.
|
||||
"Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"),
|
||||
// A ring.
|
||||
"Circle" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 8.5));
|
||||
s.push_str(&circle_path(10.0, 10.0, 5.0));
|
||||
s.push_str("B*\n");
|
||||
}
|
||||
// A thick X.
|
||||
"Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"),
|
||||
// A ring with sighting lines through it.
|
||||
"CrossHairs" => {
|
||||
s.push_str(&circle_path(10.0, 10.0, 7.0));
|
||||
s.push_str(&circle_path(10.0, 10.0, 4.5));
|
||||
s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n");
|
||||
}
|
||||
// A five-pointed star.
|
||||
"Star" => {
|
||||
let points: Vec<String> = (0..10)
|
||||
.map(|i| {
|
||||
let r = if i % 2 == 0 { 9.5 } else { 3.9 };
|
||||
let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0;
|
||||
format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin()))
|
||||
})
|
||||
.collect();
|
||||
s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l ")));
|
||||
}
|
||||
// Block arrows.
|
||||
"RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"),
|
||||
"UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"),
|
||||
_ => {
|
||||
s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n");
|
||||
s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::modify::{n, rich_text};
|
||||
use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style};
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn num_array(v: &[f64]) -> Object {
|
||||
Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn rgb(c: Rgb) -> Object {
|
||||
num_array(&c.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
pub(crate) fn finite(v: &[f64]) -> bool {
|
||||
v.iter().all(|x| x.is_finite() && x.abs() < 1e7)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize(r: [f64; 4]) -> [f64; 4] {
|
||||
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
|
||||
}
|
||||
|
||||
pub(crate) fn bounds(points: impl Iterator<Item = [f64; 2]>) -> Option<[f64; 4]> {
|
||||
points.fold(None, |acc, [x, y]| match acc {
|
||||
None => Some([x, y, x, y]),
|
||||
Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
|
||||
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
|
||||
}
|
||||
|
||||
pub(crate) fn ending_draws(ending: LineEnding) -> bool {
|
||||
ending != LineEnding::None
|
||||
}
|
||||
|
||||
pub(crate) fn ending_pair(start: LineEnding, end: LineEnding) -> Object {
|
||||
Object::Array(vec![Object::name(start.name()), Object::name(end.name())])
|
||||
}
|
||||
|
||||
/// Validate and compute `/Rect` for a new comment.
|
||||
pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}"));
|
||||
let half = style.width.max(0.0) / 2.0;
|
||||
let r = match shape {
|
||||
Shape::Note { at, .. } | Shape::Attachment { at, .. } => {
|
||||
if !finite(at) {
|
||||
return Err(bad("position"));
|
||||
}
|
||||
[at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]]
|
||||
}
|
||||
Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => {
|
||||
if quads.is_empty() || !quads.iter().all(|q| finite(q)) {
|
||||
return Err(bad("text area (no quadrilaterals)"));
|
||||
}
|
||||
bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::<Vec<_>>()))
|
||||
.ok_or_else(|| bad("text area"))?
|
||||
}
|
||||
Shape::Rectangle { rect }
|
||||
| Shape::Oval { rect }
|
||||
| Shape::TextBox { rect, .. }
|
||||
| Shape::Typewriter { rect, .. }
|
||||
| Shape::Stamp { rect, .. }
|
||||
| Shape::TypedSignature { rect, .. }
|
||||
| Shape::Mark { rect, .. } => {
|
||||
let r = normalize(*rect);
|
||||
if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
|
||||
return Err(bad("rectangle (too small)"));
|
||||
}
|
||||
r
|
||||
}
|
||||
Shape::CustomStamp { rect, .. } => {
|
||||
let r = normalize(*rect);
|
||||
// Image signatures may be very thin; a positive PDF appearance box still works.
|
||||
if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] {
|
||||
return Err(bad("rectangle (empty)"));
|
||||
}
|
||||
r
|
||||
}
|
||||
Shape::Line { from, to, start, end } => {
|
||||
if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 {
|
||||
return Err(bad("line (too short)"));
|
||||
}
|
||||
let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) {
|
||||
return Err(bad("drawing (no points)"));
|
||||
}
|
||||
// Strokes of three or more points are drawn as curves, which stay within their
|
||||
// points and control points.
|
||||
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
|
||||
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
|
||||
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
|
||||
});
|
||||
grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0)
|
||||
}
|
||||
Shape::Polygon { vertices, cloud } => {
|
||||
let b = bounds(vertices.iter().copied())
|
||||
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
|
||||
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
|
||||
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
|
||||
}
|
||||
Shape::PolyLine { vertices, start, end } => {
|
||||
let b = bounds(vertices.iter().copied())
|
||||
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
|
||||
let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra)
|
||||
}
|
||||
Shape::Callout { rect, knee, point, .. } => {
|
||||
let r = normalize(*rect);
|
||||
if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
|
||||
return Err(bad("callout"));
|
||||
}
|
||||
let pad = half + appearance::arrow_size(style.width) + 1.0;
|
||||
let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad);
|
||||
[r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])]
|
||||
}
|
||||
Shape::Caret { rect } => {
|
||||
let r = normalize(*rect);
|
||||
if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
|
||||
return Err(bad("caret"));
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
d.set(b"Contents".to_vec(), PdfString::text(contents));
|
||||
if !author.is_empty() {
|
||||
d.set(b"T".to_vec(), PdfString::text(author));
|
||||
}
|
||||
if let Some(date) = &meta.date {
|
||||
d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
}
|
||||
if !meta.id.is_empty() {
|
||||
d.set(b"NM".to_vec(), PdfString::text(&meta.id));
|
||||
}
|
||||
d.set(b"P".to_vec(), Object::Ref(page));
|
||||
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT));
|
||||
d
|
||||
}
|
||||
|
||||
/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers).
|
||||
fn subject(shape: &Shape) -> &'static str {
|
||||
match shape {
|
||||
Shape::Note { .. } => "Sticky Note",
|
||||
Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight",
|
||||
Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline",
|
||||
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough",
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly",
|
||||
Shape::Rectangle { .. } => "Rectangle",
|
||||
Shape::Oval { .. } => "Oval",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow",
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::Ink { .. } => "Pencil",
|
||||
Shape::TextBox { .. } => "Text Box",
|
||||
Shape::Typewriter { .. } => "Typewriter",
|
||||
Shape::Mark { mark, .. } => mark.label(),
|
||||
Shape::Signature { .. } => "Signature",
|
||||
Shape::Redact { .. } => "Redact",
|
||||
Shape::Stamp { stamp, .. } => match stamp.group() {
|
||||
StampGroup::Dynamic => "Dynamic stamp",
|
||||
StampGroup::SignHere => "Sign Here",
|
||||
StampGroup::StandardBusiness => "Stamp",
|
||||
},
|
||||
Shape::CustomStamp { .. } => "Stamp",
|
||||
Shape::TypedSignature { .. } => "Signature",
|
||||
Shape::Polygon { cloud: true, .. } => "Cloud",
|
||||
Shape::Polygon { .. } => "Polygon",
|
||||
Shape::PolyLine { .. } => "Polygonal Line",
|
||||
Shape::Callout { .. } => "Callout",
|
||||
Shape::Caret { .. } => "Inserted Text",
|
||||
Shape::Attachment { .. } => "File Attachment",
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a callout's leader line meets its text box: the middle of the side facing `knee`.
|
||||
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
|
||||
let [x0, y0, x1, y1] = normalize(rect);
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
if knee[0] < x0 {
|
||||
[x0, cy]
|
||||
} else if knee[0] > x1 {
|
||||
[x1, cy]
|
||||
} else if knee[1] > y1 {
|
||||
[cx, y1]
|
||||
} else {
|
||||
[cx, y0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a comment; returns its index in the page's `/Annots`.
|
||||
pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result<usize, AnnotError> {
|
||||
let page = page_ref(doc, new.page)?;
|
||||
let style = &new.style;
|
||||
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
let rect = rect_for(&new.shape, style)?;
|
||||
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
|
||||
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
|
||||
let opacity = style.opacity.clamp(0.0, 1.0);
|
||||
if opacity < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
let border = |d: &mut Dict| {
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
|
||||
bs.set(b"S".to_vec(), Object::name("S"));
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
};
|
||||
let mut popup = None;
|
||||
match &new.shape {
|
||||
Shape::Note { icon, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name(icon.name()));
|
||||
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
d.set(b"Open".to_vec(), Object::Bool(false));
|
||||
popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]);
|
||||
}
|
||||
Shape::TextMarkup { quads, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
|
||||
}
|
||||
Shape::Redact { quads, overlay, look } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
|
||||
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
|
||||
if !overlay.is_empty() {
|
||||
d.set(b"OverlayText".to_vec(), PdfString::text(overlay));
|
||||
let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
|
||||
let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
|
||||
d.set(
|
||||
b"DA".to_vec(),
|
||||
PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()),
|
||||
);
|
||||
d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
|
||||
if look.repeat {
|
||||
d.set(b"Repeat".to_vec(), Object::Bool(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Rectangle { .. } | Shape::Oval { .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
if let Some(f) = style.fill {
|
||||
d.set(b"IC".to_vec(), rgb(f));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Line { from, to, start, end } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]]));
|
||||
if ending_draws(*start) || ending_draws(*end) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Mark { mark, .. } => {
|
||||
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));
|
||||
d.set(b"Name".to_vec(), Object::name(stamp.name()));
|
||||
// Marks the stamp as drawn by PdfCraft: other stamps with standard names keep
|
||||
// their own artwork.
|
||||
d.set(b"PCStamp".to_vec(), Object::Bool(true));
|
||||
if let Some(b) = by {
|
||||
d.set(b"PCByLine".to_vec(), PdfString::text(b));
|
||||
}
|
||||
// The label is drawn for the page as displayed. A later page rotation is a new edit.
|
||||
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::TypedSignature { contours, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name("PCTypedSignature"));
|
||||
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
|
||||
d.set(b"PCOutline".to_vec(), Object::Array(list));
|
||||
}
|
||||
Shape::CustomStamp { name, picture, image, size, .. } => {
|
||||
let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
|
||||
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
|
||||
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
|
||||
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
|
||||
// An image signature's rectangle is in user space; on a turned page its appearance is
|
||||
// turned back (see `appearance::build`), so it reads upright as displayed.
|
||||
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if turn != 0 {
|
||||
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect();
|
||||
d.set(b"InkList".to_vec(), Object::Array(list));
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Polygon { vertices, cloud } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
if let Some(f) = style.fill {
|
||||
d.set(b"IC".to_vec(), rgb(f));
|
||||
}
|
||||
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
|
||||
if *cloud {
|
||||
d.set(b"IT".to_vec(), Object::name("PolygonCloud"));
|
||||
let mut be = Dict::new();
|
||||
be.set(b"S".to_vec(), Object::name("C"));
|
||||
be.set(b"I".to_vec(), Object::Int(1));
|
||||
d.set(b"BE".to_vec(), Object::Dict(be));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::PolyLine { vertices, start, end } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
|
||||
if ending_draws(*start) || ending_draws(*end) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Caret { .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Sy".to_vec(), Object::name("None"));
|
||||
}
|
||||
Shape::Attachment { icon, file, data, .. } => {
|
||||
if file.trim().is_empty() {
|
||||
return Err(AnnotError::Invalid("the attached file needs a name".into()));
|
||||
}
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Name".to_vec(), Object::name(icon.name()));
|
||||
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
// The file: an embedded file stream inside a file specification (§7.11.4).
|
||||
let mut params = Dict::new();
|
||||
params.set(b"Size".to_vec(), Object::Int(data.len() as i64));
|
||||
if let Some(date) = &meta.date {
|
||||
params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
}
|
||||
let mut ef = Dict::new();
|
||||
ef.set(b"Type".to_vec(), Object::name("EmbeddedFile"));
|
||||
ef.set(b"Params".to_vec(), Object::Dict(params));
|
||||
let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data)));
|
||||
let mut efd = Dict::new();
|
||||
efd.set(b"F".to_vec(), Object::Ref(ef));
|
||||
efd.set(b"UF".to_vec(), Object::Ref(ef));
|
||||
let mut fs = Dict::new();
|
||||
fs.set(b"Type".to_vec(), Object::name("Filespec"));
|
||||
fs.set(b"F".to_vec(), PdfString::text(file.trim()));
|
||||
fs.set(b"UF".to_vec(), PdfString::text(file.trim()));
|
||||
fs.set(b"EF".to_vec(), Object::Dict(efd));
|
||||
d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs))));
|
||||
if new.contents.is_empty() {
|
||||
d.set(b"Contents".to_vec(), PdfString::text(file.trim()));
|
||||
}
|
||||
}
|
||||
Shape::Callout { rect: tb, knee, point, ending, .. } => {
|
||||
let tb = normalize(*tb);
|
||||
let attach = callout_attach(tb, *knee);
|
||||
d.set(b"IT".to_vec(), Object::name("FreeTextCallout"));
|
||||
d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]]));
|
||||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||||
// The text box inside /Rect (§12.5.6.6 /RD).
|
||||
d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]]));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &new.shape {
|
||||
Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => {
|
||||
if matches!(new.shape, Shape::Typewriter { .. }) {
|
||||
d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter"));
|
||||
}
|
||||
let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 };
|
||||
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
|
||||
d.set(b"Q".to_vec(), Object::Int(0));
|
||||
if let Some(f) = style.fill {
|
||||
d.set(b"C".to_vec(), rgb(f));
|
||||
}
|
||||
border(&mut d);
|
||||
rich_text(&mut d);
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
if !matches!(new.shape, Shape::Callout { .. }) {
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if fill_sign_shape(&new.shape) {
|
||||
// Survives a later subject edit, so flatten-on-save still finds the mark.
|
||||
d.set(b"PCFillSign".to_vec(), Object::Bool(true));
|
||||
}
|
||||
// A redaction's overlay text becomes page content when it is applied, where `?` would be there
|
||||
// for good: refused before anything is added. A comment's own text is stored as typed and
|
||||
// drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`).
|
||||
if d.name(b"Subtype") == Some(b"Redact")
|
||||
&& let Some((what, c)) = appearance::undrawable_text(&d)
|
||||
{
|
||||
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
|
||||
}
|
||||
let r = doc.add(Object::Dict(d.clone()));
|
||||
draw_appearance(doc, r)?;
|
||||
let mut list = annots(doc, page);
|
||||
let index = list.len();
|
||||
list.push(Object::Ref(r));
|
||||
if let Some(pr) = popup {
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
p.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
p.set(b"Rect".to_vec(), num_array(&pr));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(r));
|
||||
p.set(b"Open".to_vec(), Object::Bool(false));
|
||||
p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
let pref = doc.add(Object::Dict(p));
|
||||
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
|
||||
list.push(Object::Ref(pref));
|
||||
}
|
||||
set_annots(doc, page, list)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
|
||||
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
|
||||
/// box is fitted to `/Rect` by PDF viewers.
|
||||
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
|
||||
let matrix = match rotation {
|
||||
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
|
||||
180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0],
|
||||
270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() {
|
||||
return Err(AnnotError::Invalid("only a picture stamp can be oriented".into()));
|
||||
}
|
||||
let normal = d
|
||||
.get(b"AP")
|
||||
.map(|ap| doc.resolve(ap))
|
||||
.and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N")))
|
||||
.ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?;
|
||||
let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else {
|
||||
return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into()));
|
||||
};
|
||||
stream.dict.set(b"Matrix".to_vec(), num_array(&matrix));
|
||||
doc.set(normal, Object::Stream(stream));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use super::create::{finite, num_array};
|
||||
use super::lookup::{annot_dict, annots, page_refs};
|
||||
use super::modify::refuse_foreign_text;
|
||||
use super::types::NOTE_SIZE;
|
||||
use super::*;
|
||||
|
||||
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
|
||||
/// text (a restyle, a new size or colour).
|
||||
///
|
||||
/// A comment whose text the standard fonts can't draw, with an appearance another program drew
|
||||
/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in
|
||||
/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?`
|
||||
/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn.
|
||||
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
refuse_foreign_text(doc, &annot_dict(doc, r))?;
|
||||
draw_appearance(doc, r)
|
||||
}
|
||||
|
||||
/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style.
|
||||
pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> {
|
||||
if let Err(e) = set_appearance(doc, r) {
|
||||
doc.set(r, Object::Dict(before));
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard
|
||||
/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`,
|
||||
/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that
|
||||
/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font
|
||||
/// can't show, and for display-only copies.
|
||||
pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) };
|
||||
apply_appearance(doc, r, &d, stream)
|
||||
}
|
||||
|
||||
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
|
||||
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
|
||||
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
|
||||
let mut stream = appearance::build(d)?;
|
||||
// Picture-stamp restyling must retain the placement's page-axis correction.
|
||||
if d.get(b"PCPicture").is_some() {
|
||||
let matrix = d.get(b"AP").and_then(|ap| {
|
||||
let ap = doc.resolve(ap);
|
||||
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
|
||||
let Object::Stream(normal) = normal.as_ref() else { return None };
|
||||
normal.dict.get(b"Matrix").cloned()
|
||||
});
|
||||
if let Some(matrix) = matrix {
|
||||
stream.dict.set(b"Matrix".to_vec(), matrix);
|
||||
}
|
||||
}
|
||||
Some(stream)
|
||||
}
|
||||
|
||||
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
|
||||
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
|
||||
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
|
||||
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
|
||||
let ap = doc.add(Object::Stream(stream));
|
||||
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
|
||||
// appearances would show the previous look on press or hover, so they go with the old /N.
|
||||
let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
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);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Display appearances built for annotations without any, reused while the annotation
|
||||
/// dictionaries they were drawn from are unchanged.
|
||||
///
|
||||
/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary
|
||||
/// alone, but the display copy is rebuilt from the edited document on every refresh: without
|
||||
/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit
|
||||
/// commit). Entries are keyed by annotation reference; object numbers are never reused within
|
||||
/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear
|
||||
/// the cache on any refresh that may change annotations (comments, structural and full
|
||||
/// refreshes) and keep it for content-only ones.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AppearanceCache {
|
||||
entries: std::collections::HashMap<ObjRef, Stream>,
|
||||
bytes: usize,
|
||||
hits: u64,
|
||||
builds: u64,
|
||||
}
|
||||
|
||||
impl AppearanceCache {
|
||||
/// Cached appearance data restarts past this instead of growing without bound.
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Forget every entry (an annotation edit may have changed the dictionaries).
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn get(&mut self, r: ObjRef) -> Option<Stream> {
|
||||
let stream = self.entries.get(&r)?.clone();
|
||||
self.hits += 1;
|
||||
Some(stream)
|
||||
}
|
||||
|
||||
fn insert(&mut self, r: ObjRef, stream: &Stream) {
|
||||
self.builds += 1;
|
||||
if self.entries.len() >= 1_000_000 {
|
||||
self.clear();
|
||||
}
|
||||
if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES {
|
||||
self.bytes += stream.raw.len();
|
||||
self.entries.insert(r, stream.clone());
|
||||
}
|
||||
// Else over budget: keep serving the entries already cached instead of growing.
|
||||
}
|
||||
|
||||
/// Cache hits so far (reused appearances); for tests.
|
||||
pub fn hits(&self) -> u64 {
|
||||
self.hits
|
||||
}
|
||||
|
||||
/// Appearances built so far; for tests.
|
||||
pub fn builds(&self) -> u64 {
|
||||
self.builds
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one
|
||||
/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no
|
||||
/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes,
|
||||
/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file
|
||||
/// as it was. Links, form fields and pop-ups keep their own handling.
|
||||
pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
|
||||
with_missing_appearances_cached(doc, &mut AppearanceCache::default())
|
||||
}
|
||||
|
||||
/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose
|
||||
/// dictionary is unchanged since the cached build keep their stored appearance instead of
|
||||
/// 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;
|
||||
}
|
||||
let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
|
||||
if has_normal {
|
||||
continue;
|
||||
}
|
||||
if let Some(stream) = cache.get(r) {
|
||||
// Fails only when `r` isn't a dictionary, which the cached build ruled out.
|
||||
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
|
||||
continue;
|
||||
}
|
||||
// Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its
|
||||
// `/AP`, if any, has no `/N` to keep here.
|
||||
let other_stamp = || {
|
||||
let mut bare = d.clone();
|
||||
bare.remove(b"AP");
|
||||
appearance::display_stamp(&bare)
|
||||
};
|
||||
let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue };
|
||||
cache.insert(r, &stream);
|
||||
// Fails only when `r` isn't a dictionary, which the build just ruled out.
|
||||
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
|
||||
}
|
||||
}
|
||||
copy
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use super::create::finite;
|
||||
use super::draw::set_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
|
||||
use super::modify::{fillable, touch, unlocked};
|
||||
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::*;
|
||||
|
||||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One comment as the viewer lists it (the same fields and rules as
|
||||
/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to
|
||||
/// refresh the comment list after a comment edit without re-inspecting the whole document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Summary {
|
||||
pub page: usize,
|
||||
pub index: usize,
|
||||
pub subtype: String,
|
||||
pub author: Option<String>,
|
||||
pub contents: Option<String>,
|
||||
/// `/M` as written (a PDF date string).
|
||||
pub modified: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
|
||||
pub parent: Option<(usize, usize)>,
|
||||
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
|
||||
pub grouped: bool,
|
||||
pub rect: [f32; 4],
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub state: Option<String>,
|
||||
pub quads: Vec<[f32; 8]>,
|
||||
pub locked: bool,
|
||||
pub intent: Option<String>,
|
||||
/// A Fill & Sign mark ([`is_fill_sign`]).
|
||||
pub fill_sign: bool,
|
||||
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
|
||||
pub no_rotate: bool,
|
||||
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
|
||||
/// (`/Vertices`), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
let o = doc.resolve(d.get(key)?);
|
||||
let s = match &*o {
|
||||
Object::String(s) => s.to_text(),
|
||||
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let s = s.trim_matches('\0').trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup
|
||||
/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia,
|
||||
/// 3D, PrinterMark, TrapNet, Watermark) are not.
|
||||
pub fn is_comment_subtype(subtype: &str) -> bool {
|
||||
!matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark")
|
||||
}
|
||||
|
||||
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
|
||||
/// follows it, within a few steps (a cycle stops there).
|
||||
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
for _ in 0..8 {
|
||||
match &*doc.get(r) {
|
||||
Object::Ref(next) => r = *next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
|
||||
pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(pages) = page_refs(doc) else { return out };
|
||||
let lists: Vec<Vec<Object>> = pages.iter().map(|p| annots(doc, *p)).collect();
|
||||
// Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference.
|
||||
let mut slots: std::collections::HashMap<ObjRef, (usize, usize)> = std::collections::HashMap::new();
|
||||
for (page, list) in lists.iter().enumerate() {
|
||||
for (index, entry) in list.iter().enumerate() {
|
||||
if let Some(r) = entry.as_ref() {
|
||||
slots.entry(final_ref(doc, r)).or_insert((page, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (page, list) in lists.iter().enumerate() {
|
||||
for (index, entry) in list.iter().enumerate() {
|
||||
let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r)));
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue };
|
||||
if !is_comment_subtype(&subtype) {
|
||||
continue;
|
||||
}
|
||||
let nums = |k: &[u8]| -> Vec<f32> {
|
||||
d.get(k)
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let r = nums(b"Rect");
|
||||
let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] };
|
||||
let c = nums(b"C");
|
||||
let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
|
||||
if subtype == "FreeText"
|
||||
&& let Some(da) = text_value(doc, d, b"DA")
|
||||
{
|
||||
let t: Vec<&str> = da.split_whitespace().collect();
|
||||
if let Some(i) = t.iter().position(|x| *x == "rg")
|
||||
&& i >= 3
|
||||
{
|
||||
let f = |k: usize| t[k].parse::<f32>().unwrap_or(0.0);
|
||||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||||
}
|
||||
}
|
||||
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 irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
|
||||
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
|
||||
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
|
||||
out.push(Summary {
|
||||
page,
|
||||
index,
|
||||
subtype,
|
||||
author: text_value(doc, d, b"T"),
|
||||
// Acrobat ends lines with CR, which the interface would draw as nothing (keep in
|
||||
// step with `pdfcraft_render::inspect`).
|
||||
contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
name: text_value(doc, d, b"NM"),
|
||||
in_reply_to,
|
||||
parent,
|
||||
grouped: d.name(b"RT") == Some(b"Group"),
|
||||
rect,
|
||||
color,
|
||||
state: text_value(doc, d, b"State"),
|
||||
quads,
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
|
||||
fill_sign: is_fill_sign(doc, d),
|
||||
no_rotate: drawn_upright(doc, d),
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
out
|
||||
}
|
||||
|
||||
/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn).
|
||||
/// `None` leaves a value as it is.
|
||||
pub fn set_info(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
index: usize,
|
||||
author: Option<&str>,
|
||||
subject: Option<&str>,
|
||||
icon: Option<NoteIcon>,
|
||||
meta: &Meta,
|
||||
) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text");
|
||||
if icon.is_some() && !is_note {
|
||||
return Err(AnnotError::Invalid("only sticky notes have an icon".into()));
|
||||
}
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some(a) = author {
|
||||
d.set(b"T".to_vec(), PdfString::text(a));
|
||||
}
|
||||
if let Some(s) = subject {
|
||||
d.set(b"Subj".to_vec(), PdfString::text(s));
|
||||
}
|
||||
if let Some(i) = icon {
|
||||
d.set(b"Name".to_vec(), Object::name(i.name()));
|
||||
}
|
||||
touch(d, meta);
|
||||
})?;
|
||||
if icon.is_some() {
|
||||
set_appearance(doc, r)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What Comment properties shows for one comment.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Props {
|
||||
pub subtype: String,
|
||||
pub author: String,
|
||||
pub subject: String,
|
||||
pub color: Option<Rgb>,
|
||||
pub opacity: f64,
|
||||
/// Border / line width, for shapes, lines, drawings and text boxes.
|
||||
pub width: Option<f64>,
|
||||
/// Notes only.
|
||||
pub icon: Option<NoteIcon>,
|
||||
/// `/M`, as written.
|
||||
pub modified: Option<String>,
|
||||
/// The appearance can be redrawn (so colour, opacity and width can change).
|
||||
pub restylable: bool,
|
||||
/// The Locked flag.
|
||||
pub locked: bool,
|
||||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
/// Whether the comment has a fill control (see [`fillable`]).
|
||||
pub fillable: bool,
|
||||
/// The interior colour (`None`: no fill).
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let p = page_ref(doc, page).ok()?;
|
||||
let entry = annots(doc, p).get(index).cloned()?;
|
||||
let obj = doc.resolve(&entry);
|
||||
let d = obj.as_dict()?;
|
||||
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" | "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(),
|
||||
color,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
restylable: appearance::build(d).is_some(),
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
}
|
||||
|
||||
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
|
||||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||||
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],
|
||||
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
|
||||
_ => return None,
|
||||
};
|
||||
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
|
||||
pub(crate) fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
|
||||
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);
|
||||
match d.name(b"Subtype")? {
|
||||
b"Line" | b"PolyLine" => match d.get(b"LE") {
|
||||
None => Some(vec![LineEnding::None, LineEnding::None]),
|
||||
Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(),
|
||||
},
|
||||
b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
+28
-2593
File diff suppressed because it is too large.
Load diff
@@ -320,7 +320,7 @@ pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), Annot
|
||||
let (p, _) = link_at(doc, page, index)?;
|
||||
let mut list = annots(doc, p);
|
||||
list.remove(index);
|
||||
set_annots(doc, p, list)
|
||||
Ok(set_annots(doc, p, list)?)
|
||||
}
|
||||
|
||||
/// Remove every link on `pages` (all when `None`). Returns how many went.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
use super::info::text_value;
|
||||
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
|
||||
use super::*;
|
||||
|
||||
// ── pages and /Annots ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The page object of each leaf page, in order.
|
||||
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)?;
|
||||
Ok(PageWalk::new(doc, pages).collect())
|
||||
}
|
||||
|
||||
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
|
||||
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
|
||||
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
|
||||
/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once,
|
||||
/// and at most a million nodes and four million `/Kids` entries are read.
|
||||
pub(crate) struct PageWalk<'a> {
|
||||
doc: &'a Document,
|
||||
seen: std::collections::HashSet<ObjRef>,
|
||||
pub(crate) pending: Vec<(std::sync::Arc<Object>, usize)>,
|
||||
/// `/Kids` entries read so far.
|
||||
pub(crate) examined: usize,
|
||||
}
|
||||
|
||||
impl<'a> PageWalk<'a> {
|
||||
pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self {
|
||||
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
|
||||
Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for PageWalk<'_> {
|
||||
type Item = ObjRef;
|
||||
|
||||
fn next(&mut self) -> Option<ObjRef> {
|
||||
while let Some((kids, next)) = self.pending.last_mut() {
|
||||
let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else {
|
||||
self.pending.pop();
|
||||
continue;
|
||||
};
|
||||
let node = kid.as_ref();
|
||||
*next += 1;
|
||||
self.examined += 1;
|
||||
let Some(node) = node else { continue };
|
||||
if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
}
|
||||
if !self.seen.insert(node) {
|
||||
continue;
|
||||
}
|
||||
let obj = self.doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
match d.get(b"Kids") {
|
||||
// A `/Kids` array object shared by several nodes is read under the first: under
|
||||
// the others it would only list nodes already seen.
|
||||
Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => {
|
||||
if self.seen.insert(*array) {
|
||||
self.pending.push((self.doc.get(*array), 0));
|
||||
}
|
||||
}
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)),
|
||||
_ => return Some(node),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
|
||||
page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page))
|
||||
}
|
||||
|
||||
/// The entries of a page's `/Annots` (references or inline dictionaries).
|
||||
pub(crate) fn annots(doc: &Document, page: ObjRef) -> Vec<Object> {
|
||||
let obj = doc.get(page);
|
||||
let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() };
|
||||
let list = doc.resolve(a).as_array().cloned().unwrap_or_default();
|
||||
#[cfg(test)]
|
||||
ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len()));
|
||||
list
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Entries copied out of pages' `/Annots` arrays.
|
||||
pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty
|
||||
/// list removes a direct `/Annots`.
|
||||
pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), pdfcraft_cos::CosError> {
|
||||
let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots"));
|
||||
match existing {
|
||||
Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)),
|
||||
_ => doc.update_dict(page, |d| {
|
||||
if list.is_empty() {
|
||||
d.remove(b"Annots");
|
||||
} else {
|
||||
d.set(b"Annots".to_vec(), Object::Array(list));
|
||||
}
|
||||
})?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
|
||||
pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let mut list = annots(doc, p);
|
||||
let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let r = match entry {
|
||||
Object::Ref(r) if doc.get(r).as_dict().is_some() => r,
|
||||
Object::Dict(d) => {
|
||||
let r = doc.add(Object::Dict(d));
|
||||
list[index] = Object::Ref(r);
|
||||
set_annots(doc, p, list)?;
|
||||
r
|
||||
}
|
||||
_ => return Err(AnnotError::NoSuchAnnotation { page, index }),
|
||||
};
|
||||
Ok((p, r))
|
||||
}
|
||||
|
||||
pub(crate) fn annot_dict(doc: &Document, r: ObjRef) -> Dict {
|
||||
doc.get(r).as_dict().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature.
|
||||
///
|
||||
/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft
|
||||
/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is
|
||||
/// "Signature").
|
||||
pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
|
||||
if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
|
||||
return true;
|
||||
}
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") {
|
||||
return true;
|
||||
}
|
||||
if subtype == b"Stamp" {
|
||||
if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) {
|
||||
return true;
|
||||
}
|
||||
if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials"))
|
||||
&& matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
|
||||
}
|
||||
|
||||
pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
|
||||
d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with
|
||||
/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags).
|
||||
pub(crate) fn drawn_upright(doc: &Document, d: &Dict) -> bool {
|
||||
let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() {
|
||||
Some(Object::Int(i)) => *i,
|
||||
Some(Object::Real(r)) => *r as i64,
|
||||
_ => 0,
|
||||
};
|
||||
u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0)
|
||||
}
|
||||
|
||||
/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden).
|
||||
pub fn has_visible_fill_sign(doc: &Document) -> bool {
|
||||
let Ok(pages) = page_refs(doc) else { return false };
|
||||
for page in pages {
|
||||
for entry in annots(doc, page) {
|
||||
let obj = doc.resolve(&entry);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let flags = annotation_flags(doc, d);
|
||||
if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 {
|
||||
continue;
|
||||
}
|
||||
if is_fill_sign(doc, d) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn fill_sign_shape(shape: &Shape) -> bool {
|
||||
match shape {
|
||||
Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true,
|
||||
Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded image of a Fill & Sign image signature or initials (0-based target).
|
||||
/// Other stamps have appearances that can't be represented by this image alone.
|
||||
pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result<Option<ObjRef>, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let list = annots(doc, p);
|
||||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let obj = doc.resolve(entry);
|
||||
let Some(d) = obj.as_dict() else { return Ok(None) };
|
||||
Ok((d.name(b"Subtype") == Some(b"Stamp")
|
||||
&& matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials"))
|
||||
&& matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))))
|
||||
.then(|| d.reference(b"PCPicture"))
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back
|
||||
/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by
|
||||
/// another app. The picture appears turned by the page's current rotation less this.
|
||||
pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let list = annots(doc, p);
|
||||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
use super::create::{
|
||||
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb,
|
||||
};
|
||||
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{line_endings_of, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
|
||||
use super::types::{
|
||||
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
|
||||
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
|
||||
pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> {
|
||||
let drawn_elsewhere =
|
||||
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none();
|
||||
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) {
|
||||
return Err(AnnotError::Undrawable(format!(
|
||||
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
|
||||
c.escape_debug(),
|
||||
u32::from(c)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format a number for content streams and DA strings.
|
||||
pub(crate) fn n(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
// ── edits ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
|
||||
/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing.
|
||||
pub(crate) fn rich_text(d: &mut Dict) {
|
||||
let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default();
|
||||
let (col, size) = appearance::parse_da(d);
|
||||
let hex = format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
(col[0].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(col[1].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(col[2].clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
);
|
||||
let align = match d.get(b"Q").and_then(Object::as_int) {
|
||||
Some(1) => "center",
|
||||
Some(2) => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """);
|
||||
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", esc(line.trim_end_matches('\r')))).collect();
|
||||
let style = format!(
|
||||
"font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal",
|
||||
n(size)
|
||||
);
|
||||
let rc = format!(
|
||||
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</body>"
|
||||
);
|
||||
d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size))));
|
||||
d.set(b"RC".to_vec(), PdfString::text(&rc));
|
||||
}
|
||||
|
||||
pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
|
||||
if let Some(date) = &meta.date {
|
||||
d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
|
||||
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let list = annots(doc, p);
|
||||
let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
if let Some(r) = target.as_ref() {
|
||||
unlocked(doc, r)?;
|
||||
}
|
||||
// An inline comment has no reference to collect; the pop-up it names still goes.
|
||||
let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
|
||||
let doomed = with_replies_and_popups(doc, &list, seed);
|
||||
let kept: Vec<Object> = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect();
|
||||
Ok(set_annots(doc, p, kept)?)
|
||||
}
|
||||
|
||||
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
|
||||
/// one of them, or it is written inline (a file can, though the standard asks for references) and
|
||||
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
|
||||
/// for the walk to collect, so it is matched here.
|
||||
pub fn goes_with(entry: &Object, going: &HashSet<ObjRef>) -> bool {
|
||||
match entry.as_ref() {
|
||||
Some(r) => going.contains(&r),
|
||||
None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or
|
||||
/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their
|
||||
/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no
|
||||
/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or
|
||||
/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also
|
||||
/// matches inline entries.
|
||||
pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator<Item = ObjRef>) -> HashSet<ObjRef> {
|
||||
let mut hanging: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
|
||||
for e in list {
|
||||
let obj = doc.resolve(e);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
// What goes with the annotation this entry hangs off: the entry, or for an inline one the
|
||||
// pop-up it names.
|
||||
let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue };
|
||||
for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() {
|
||||
hanging.entry(on).or_default().push(goes);
|
||||
}
|
||||
}
|
||||
let mut going = HashSet::new();
|
||||
let mut todo: Vec<ObjRef> = doomed.into_iter().collect();
|
||||
while let Some(r) = todo.pop() {
|
||||
if !going.insert(r) {
|
||||
continue;
|
||||
}
|
||||
todo.extend(hanging.get(&r).into_iter().flatten());
|
||||
todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")));
|
||||
}
|
||||
going
|
||||
}
|
||||
|
||||
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
|
||||
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
|
||||
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Contents".to_vec(), PdfString::text(text));
|
||||
// The rich-text version follows the new plain text (text boxes), or would contradict it.
|
||||
if free_text {
|
||||
rich_text(d);
|
||||
} else {
|
||||
d.remove(b"RC");
|
||||
}
|
||||
touch(d, meta);
|
||||
})?;
|
||||
if free_text {
|
||||
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
|
||||
// text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even
|
||||
// where the standard fonts can't draw some of it (marked and reported, see
|
||||
// `draw_appearance`).
|
||||
match fitted_box(doc, r, text) {
|
||||
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
|
||||
None => draw_appearance(doc, r)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and
|
||||
/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old
|
||||
/// rectangle (the box can't be measured, so only the appearance is redrawn).
|
||||
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
|
||||
let d = annot_dict(doc, r);
|
||||
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
|
||||
let (_, size) = appearance::parse_da(&d);
|
||||
let pad = 2.0 + border_width_of(&d);
|
||||
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
|
||||
let rect = [rect[0], rect[1], rect[2], rect[3]];
|
||||
// A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place.
|
||||
let tb = if d.contains(b"CL") {
|
||||
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?;
|
||||
[rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]
|
||||
} else {
|
||||
rect
|
||||
};
|
||||
let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3]));
|
||||
let w = tb[2] - tb[0];
|
||||
let w = if w.is_finite() && w >= 1.0 {
|
||||
w
|
||||
} else {
|
||||
// No usable old width: fall back to the creation-time width (at most 300 pt).
|
||||
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
|
||||
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
|
||||
};
|
||||
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
|
||||
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
|
||||
}
|
||||
|
||||
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
|
||||
///
|
||||
/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the
|
||||
/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a
|
||||
/// second icon on the page.
|
||||
pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
|
||||
reply(doc, page, index, text, author, meta, None)
|
||||
}
|
||||
|
||||
/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a
|
||||
/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins.
|
||||
pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
|
||||
let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author });
|
||||
reply(doc, page, index, &text, author, meta, Some(state))
|
||||
}
|
||||
|
||||
fn reply(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
index: usize,
|
||||
text: &str,
|
||||
author: &str,
|
||||
meta: &Meta,
|
||||
state: Option<ReviewState>,
|
||||
) -> Result<usize, AnnotError> {
|
||||
let (p, parent) = annot_ref(doc, page, index)?;
|
||||
let pd = annot_dict(doc, parent);
|
||||
if pd.name(b"Subtype") == Some(b"Popup") {
|
||||
return Err(AnnotError::Invalid("pop-ups can't be replied to".into()));
|
||||
}
|
||||
let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0]));
|
||||
let mut d = base_dict("Text", [0.0; 4], p, text, author, meta);
|
||||
d.set(b"Rect".to_vec(), rect);
|
||||
d.set(b"IRT".to_vec(), Object::Ref(parent));
|
||||
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
d.set(b"Name".to_vec(), Object::name("Comment"));
|
||||
if let Some(c) = pd.get(b"C") {
|
||||
d.set(b"C".to_vec(), c.clone());
|
||||
}
|
||||
if let Some(s) = state {
|
||||
d.set(b"State".to_vec(), PdfString::text(s.name()));
|
||||
d.set(b"StateModel".to_vec(), PdfString::text("Review"));
|
||||
d.set(b"Subj".to_vec(), PdfString::text("Status"));
|
||||
}
|
||||
// An empty form: nothing is drawn for the reply itself.
|
||||
let mut fd = Dict::new();
|
||||
fd.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
fd.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0]));
|
||||
let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new())));
|
||||
let mut apd = Dict::new();
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap));
|
||||
d.set(b"AP".to_vec(), Object::Dict(apd));
|
||||
let r = doc.add(Object::Dict(d));
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(r));
|
||||
let i = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn flags(doc: &Document, r: ObjRef) -> i64 {
|
||||
doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked).
|
||||
pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized,
|
||||
/// restyled or deleted; its text and replies stay editable, as in Acrobat.
|
||||
pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED };
|
||||
doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with
|
||||
/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status.
|
||||
pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
|
||||
let (p, parent) = annot_ref(doc, page, index)?;
|
||||
let pd = annot_dict(doc, parent);
|
||||
if pd.name(b"Subtype") == Some(b"Popup") {
|
||||
return Err(AnnotError::Invalid("pop-ups can't be marked".into()));
|
||||
}
|
||||
let state = if marked { "Marked" } else { "Unmarked" };
|
||||
let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author });
|
||||
let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta);
|
||||
d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4])));
|
||||
d.set(b"IRT".to_vec(), Object::Ref(parent));
|
||||
d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
|
||||
d.set(b"State".to_vec(), PdfString::text(state));
|
||||
d.set(b"StateModel".to_vec(), PdfString::text("Marked"));
|
||||
let r = doc.add(Object::Dict(d));
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(r));
|
||||
let i = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`.
|
||||
pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> {
|
||||
if !finite(&[dx, dy]) {
|
||||
return Err(AnnotError::Invalid("invalid offset".into()));
|
||||
}
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let shift = |o: &Object, every: bool| -> Option<Object> {
|
||||
let a = o.as_array()?;
|
||||
Some(Object::Array(
|
||||
a.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| match v.as_f64() {
|
||||
Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }),
|
||||
_ => v.clone(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
};
|
||||
let popup = annot_dict(doc, r).reference(b"Popup");
|
||||
doc.update_dict(r, |d| {
|
||||
for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] {
|
||||
if let Some(v) = d.get(k).and_then(|o| shift(o, every)) {
|
||||
d.set(k.to_vec(), v);
|
||||
}
|
||||
}
|
||||
if let Some(Object::Array(list)) = d.get(b"InkList").cloned() {
|
||||
let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect();
|
||||
d.set(b"InkList".to_vec(), Object::Array(moved));
|
||||
}
|
||||
touch(d, meta);
|
||||
})?;
|
||||
if let Some(p) = popup
|
||||
&& doc.get(p).as_dict().is_some()
|
||||
{
|
||||
doc.update_dict(p, |d| {
|
||||
if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) {
|
||||
d.set(b"Rect".to_vec(), v);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance,
|
||||
/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their
|
||||
/// strokes and get a new one.
|
||||
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if subtype == "Stamp" {
|
||||
let rect = normalize(rect);
|
||||
if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] {
|
||||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||||
}
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
if subtype == "Ink" {
|
||||
return resize_ink(doc, r, normalize(rect), meta);
|
||||
}
|
||||
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
|
||||
}
|
||||
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> {
|
||||
let d = annot_dict(doc, r);
|
||||
let strokes: Vec<Vec<[f64; 2]>> = d
|
||||
.get(b"InkList")
|
||||
.map(|l| doc.resolve(l))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
|
||||
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
|
||||
})
|
||||
.collect();
|
||||
let Some(inner) = bounds(strokes.iter().flatten().copied()) else {
|
||||
return Err(AnnotError::Invalid("the drawing has no strokes".into()));
|
||||
};
|
||||
let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
let old: Vec<f64> = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect();
|
||||
let old = match old[..] {
|
||||
[a, b, c, e] => normalize([a, b, c, e]),
|
||||
_ => inner,
|
||||
};
|
||||
let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0));
|
||||
let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]];
|
||||
if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||||
}
|
||||
// An axis with no extent (a straight stroke) is centred rather than scaled.
|
||||
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 };
|
||||
let scaled: Vec<Vec<[f64; 2]>> = strokes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect()
|
||||
})
|
||||
.collect();
|
||||
let before = annot_dict(doc, r);
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect()));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
|
||||
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
|
||||
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
|
||||
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
|
||||
let rect = normalize(rect);
|
||||
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||||
}
|
||||
let d = annot_dict(doc, r);
|
||||
if !new_text {
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
}
|
||||
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
|
||||
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
|
||||
let (outer, cl) = match callout {
|
||||
Some(mut l) => {
|
||||
let attach = callout_attach(rect, [l[2], l[3]]);
|
||||
(l[4], l[5]) = (attach[0], attach[1]);
|
||||
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
|
||||
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
|
||||
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
|
||||
}
|
||||
None => (rect, None),
|
||||
};
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Rect".to_vec(), num_array(&outer));
|
||||
match &cl {
|
||||
Some(l) => {
|
||||
d.set(b"CL".to_vec(), num_array(l));
|
||||
d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]]));
|
||||
}
|
||||
None => {
|
||||
d.remove(b"RD");
|
||||
}
|
||||
}
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let before = d;
|
||||
if new_text {
|
||||
if let Err(e) = draw_appearance(doc, r) {
|
||||
doc.set(r, Object::Dict(before));
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
}
|
||||
|
||||
fn border_width_of(d: &Dict) -> f64 {
|
||||
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).max(0.0)
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_style(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
index: usize,
|
||||
color: Option<Rgb>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
endings: Option<&[LineEnding]>,
|
||||
meta: &Meta,
|
||||
) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
// Check before changing anything: a stale appearance would contradict the new style.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype.clone()));
|
||||
}
|
||||
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
let callout = subtype == "FreeText" && d.contains(b"CL");
|
||||
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
|
||||
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
|
||||
return Err(AnnotError::Invalid("this comment has no line endings to change".into()));
|
||||
}
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let free_text = subtype == "FreeText";
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some(c) = color {
|
||||
if free_text {
|
||||
let (_, size) = appearance::parse_da(d);
|
||||
let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
|
||||
rich_text(d);
|
||||
} else {
|
||||
d.set(b"C".to_vec(), rgb(c));
|
||||
}
|
||||
}
|
||||
if let Some(o) = opacity {
|
||||
let o = o.clamp(0.0, 1.0);
|
||||
if o < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(o));
|
||||
} else {
|
||||
d.remove(b"CA");
|
||||
}
|
||||
}
|
||||
if let Some(w) = width {
|
||||
let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default();
|
||||
bs.set(b"W".to_vec(), Object::Real(w.max(0.0)));
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
d.remove(b"Border");
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
if ends.len() == 1 {
|
||||
if let Some(ending) = ends.first() {
|
||||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||||
}
|
||||
} else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
}
|
||||
ensure_ending_room(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the
|
||||
/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new
|
||||
/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt.
|
||||
pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into()));
|
||||
}
|
||||
if !size.is_finite() || !(1.0..=400.0).contains(&size) {
|
||||
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (col, _) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes()));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
|
||||
let result = match fitted_box(doc, r, &text) {
|
||||
Some(fit) => {
|
||||
let cur = annot_dict(doc, r);
|
||||
let nums: Vec<f64> =
|
||||
cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
// Keep the top and sides; only the bottom edge moves, and only downwards.
|
||||
match four(&nums).map(normalize) {
|
||||
Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false),
|
||||
_ => apply_text_box(doc, r, fit, meta, false),
|
||||
}
|
||||
}
|
||||
None => redraw_or_restore(doc, r, before.clone()),
|
||||
};
|
||||
if result.is_err() {
|
||||
doc.set(r, Object::Dict(before));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
|
||||
pub fn fillable(subtype: &str) -> bool {
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
unlocked(doc, r)?;
|
||||
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, 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() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
if fill.is_some_and(|c| !finite(&c)) {
|
||||
return Err(AnnotError::Invalid("invalid fill colour".into()));
|
||||
}
|
||||
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(key.to_vec(), rgb(c)),
|
||||
None => {
|
||||
d.remove(key);
|
||||
}
|
||||
}
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||||
fn ensure_ending_room(d: &mut Dict) {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
if !matches!(subtype, b"Line" | b"PolyLine") {
|
||||
return;
|
||||
}
|
||||
if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) {
|
||||
return;
|
||||
}
|
||||
let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" };
|
||||
let Some(pts) = pair_points(d, key) else { return };
|
||||
let Some(bounds) = bounds(pts.iter().copied()) else { return };
|
||||
let w = border_width_of(d);
|
||||
let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0);
|
||||
let vals: Vec<f64> = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
let Some(rect) = four(&vals) else { return };
|
||||
let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])];
|
||||
if union != rect {
|
||||
d.set(b"Rect".to_vec(), num_array(&union));
|
||||
}
|
||||
}
|
||||
|
||||
fn four(v: &[f64]) -> Option<[f64; 4]> {
|
||||
Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?])
|
||||
}
|
||||
|
||||
fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
|
||||
let v: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect();
|
||||
if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(v.as_chunks::<2>().0.to_vec())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
page: usize,
|
||||
quads: &[[f64; 8]],
|
||||
replacement: &str,
|
||||
author: &str,
|
||||
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(
|
||||
doc,
|
||||
&NewAnnotation {
|
||||
page,
|
||||
shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() },
|
||||
style: strike.clone(),
|
||||
contents: String::new(),
|
||||
author: author.into(),
|
||||
},
|
||||
meta,
|
||||
)?;
|
||||
let (_, sr) = annot_ref(doc, page, s)?;
|
||||
doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?;
|
||||
// The caret sits on the baseline at the end of the last line, its point at the text.
|
||||
let xs = [last[0], last[2], last[4], last[6]];
|
||||
let ys = [last[1], last[3], last[5], last[7]];
|
||||
let x = xs.iter().copied().fold(f64::MIN, f64::max);
|
||||
let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max));
|
||||
let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0);
|
||||
let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4];
|
||||
let c = add_annotation(
|
||||
doc,
|
||||
&NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() },
|
||||
caret_meta,
|
||||
)?;
|
||||
let (_, cr) = annot_ref(doc, page, c)?;
|
||||
doc.update_dict(cr, |d| {
|
||||
d.set(b"IRT".to_vec(), Object::Ref(sr));
|
||||
d.set(b"RT".to_vec(), Object::name("Group"));
|
||||
d.set(b"Subj".to_vec(), PdfString::text("Replace Text"));
|
||||
})?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points
|
||||
/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted.
|
||||
/// Returns whether anything was erased.
|
||||
pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result<bool, AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"Ink") {
|
||||
return Err(AnnotError::Invalid("only drawings can be erased".into()));
|
||||
}
|
||||
if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 {
|
||||
return Err(AnnotError::Invalid("invalid eraser path".into()));
|
||||
}
|
||||
let strokes: Vec<Vec<[f64; 2]>> = d
|
||||
.get(b"InkList")
|
||||
.map(|l| doc.resolve(l))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
|
||||
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
|
||||
})
|
||||
.collect();
|
||||
// Distance from a point to the eraser's path (segments, or a single point).
|
||||
let near = |p: [f64; 2]| -> bool {
|
||||
if path.len() == 1 {
|
||||
return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius;
|
||||
}
|
||||
path.windows(2).any(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len2 = dx * dx + dy * dy;
|
||||
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius
|
||||
})
|
||||
};
|
||||
let mut kept: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||
let mut erased = false;
|
||||
for s in &strokes {
|
||||
// Densify so long segments are cut where the eraser crosses them.
|
||||
let mut pts: Vec<[f64; 2]> = Vec::new();
|
||||
for w in s.windows(2) {
|
||||
let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize;
|
||||
for k in 0..n {
|
||||
let t = k as f64 / n as f64;
|
||||
pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]);
|
||||
}
|
||||
}
|
||||
if let Some(last) = s.last() {
|
||||
pts.push(*last);
|
||||
}
|
||||
let mut run: Vec<[f64; 2]> = Vec::new();
|
||||
for p in pts {
|
||||
if near(p) {
|
||||
erased = true;
|
||||
if run.len() >= 2 {
|
||||
kept.push(std::mem::take(&mut run));
|
||||
}
|
||||
run.clear();
|
||||
} else {
|
||||
run.push(p);
|
||||
}
|
||||
}
|
||||
if run.len() >= 2 {
|
||||
kept.push(run);
|
||||
}
|
||||
}
|
||||
if !erased {
|
||||
return Ok(false);
|
||||
}
|
||||
if kept.is_empty() {
|
||||
delete_annotation(doc, page, index)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let width = 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 rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0);
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect()));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
set_appearance(doc, r)?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -229,6 +229,47 @@ fn delete_takes_popup_and_reply_chain() {
|
||||
assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 }));
|
||||
}
|
||||
|
||||
/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for
|
||||
/// references) goes with the comment it points at, and so does a pop-up an inline reply or an
|
||||
/// inline comment names. An unrelated inline comment stays until it is deleted itself.
|
||||
#[test]
|
||||
fn delete_takes_inline_replies_and_popups() {
|
||||
let mut doc = fixture();
|
||||
let mut popup = |note: &str| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||||
d.set(b"Contents".to_vec(), PdfString::text(note));
|
||||
doc.add(Object::Dict(d))
|
||||
};
|
||||
let (reply_popup, square_popup) = (popup("of the reply"), popup("of the square"));
|
||||
let inline = |subtype: &str, links: &[(&[u8], ObjRef)]| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
links.iter().for_each(|(key, r)| d.set(key.to_vec(), Object::Ref(*r)));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let stamp = ObjRef::new(6, 0);
|
||||
let entries = vec![
|
||||
Object::Ref(stamp),
|
||||
inline("Text", &[(b"IRT", stamp), (b"Popup", reply_popup)]),
|
||||
Object::Ref(reply_popup),
|
||||
inline("Popup", &[(b"Parent", stamp)]),
|
||||
inline("Square", &[(b"Popup", square_popup)]),
|
||||
Object::Ref(square_popup),
|
||||
];
|
||||
doc.set(ObjRef::new(5, 0), Object::Array(entries));
|
||||
let subtypes = |doc: &Document| -> Vec<String> {
|
||||
list(doc, 1).iter().map(|d| String::from_utf8_lossy(d.name(b"Subtype").unwrap()).into_owned() + ":" + &text(d, b"Contents")).collect()
|
||||
};
|
||||
delete_annotation(&mut doc, 1, 0).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
assert_eq!(subtypes(&doc), ["Square:", "Popup:of the square"]);
|
||||
delete_annotation(&mut doc, 1, 0).unwrap();
|
||||
assert!(subtypes(&reopen(&doc)).is_empty(), "the inline comment's pop-up went with it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_last_comment_removes_annots() {
|
||||
let mut doc = fixture();
|
||||
@@ -1867,3 +1908,230 @@ fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
|
||||
assert!(appearance::build(&d).is_some());
|
||||
}
|
||||
|
||||
// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ──────────
|
||||
|
||||
/// A red Line from `l` with a 1 pt border, a generous `/Rect`, and `extra` entries.
|
||||
fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Line"));
|
||||
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 600.0, 800.0].map(Object::Real).to_vec()));
|
||||
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec()));
|
||||
d.set(b"L".to_vec(), Object::Array(l.map(Object::Real).to_vec()));
|
||||
for (k, v) in extra {
|
||||
d.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The built appearance's content.
|
||||
fn line_drawing(d: &Dict) -> String {
|
||||
let s = crate::appearance::build(d).expect("the line is drawn");
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// Every straight two-point stroke (`x0 y0 m x1 y1 l S`) in a drawing.
|
||||
fn segments(content: &str) -> Vec<[f64; 4]> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
if t.len() != 7 || t[2] != "m" || t[5] != "l" || t[6] != "S" {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<f64> = [t[0], t[1], t[3], t[4]].iter().map(|x| x.parse().unwrap()).collect();
|
||||
Some([v[0], v[1], v[2], v[3]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The caption's text matrix (`a b c d e f Tm`), if any.
|
||||
fn caption_matrix(content: &str) -> Option<[f64; 6]> {
|
||||
let at = content.find(" Tm")?;
|
||||
let before: Vec<f64> = content[..at].split_whitespace().rev().take(6).map(|x| x.parse().unwrap()).collect();
|
||||
Some([before[5], before[4], before[3], before[2], before[1], before[0]])
|
||||
}
|
||||
|
||||
fn has(segs: &[[f64; 4]], want: [f64; 4]) -> bool {
|
||||
segs.iter().any(|s| s.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below() {
|
||||
let up = segments(&line_drawing(&table_178_line([72.0, 620.0, 272.0, 620.0], &[("LL", Object::Real(20.0))])));
|
||||
assert!(has(&up, [72.0, 640.0, 272.0, 640.0]), "the line is 20 above /L: {up:?}");
|
||||
assert!(has(&up, [72.0, 620.0, 72.0, 640.0]) && has(&up, [272.0, 620.0, 272.0, 640.0]), "leader lines from /L up to it: {up:?}");
|
||||
assert!(!has(&up, [72.0, 620.0, 272.0, 620.0]), "nothing along /L itself: {up:?}");
|
||||
|
||||
let down = segments(&line_drawing(&table_178_line([72.0, 540.0, 272.0, 540.0], &[("LL", Object::Real(-20.0)), ("LLE", Object::Real(10.0))])));
|
||||
assert!(has(&down, [72.0, 520.0, 272.0, 520.0]), "the line is 20 below /L: {down:?}");
|
||||
assert!(has(&down, [72.0, 540.0, 72.0, 510.0]) && has(&down, [272.0, 540.0, 272.0, 510.0]), "leaders run 10 past it: {down:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leader_offset_leaves_a_gap_and_the_endings_sit_on_the_line_itself() {
|
||||
let d = table_178_line(
|
||||
[72.0, 460.0, 272.0, 460.0],
|
||||
&[("LL", Object::Real(20.0)), ("LLO", Object::Real(10.0)), ("LE", Object::Array(vec![Object::name("OpenArrow"), Object::name("OpenArrow")]))],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
let segs = segments(&content);
|
||||
assert!(has(&segs, [72.0, 490.0, 272.0, 490.0]), "the line sits |LL| + LLO above /L: {segs:?}");
|
||||
assert!(has(&segs, [72.0, 470.0, 72.0, 490.0]) && has(&segs, [272.0, 470.0, 272.0, 490.0]), "leaders start LLO from /L: {segs:?}");
|
||||
// The open arrows' tips (`tip l`) are at the ends of the line, not of /L.
|
||||
assert!(content.contains(" 72 490 l ") && content.contains(" 272 490 l "), "arrows at the moved line: {content}");
|
||||
assert!(!content.contains(" 72 460 l "), "no arrow on /L: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_caption_breaks_the_line_around_its_text() {
|
||||
let d =
|
||||
table_178_line([72.0, 380.0, 272.0, 380.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("Inline 50 mm")))]);
|
||||
let content = line_drawing(&d);
|
||||
assert!(content.contains("(Inline 50 mm) Tj"), "the caption is drawn: {content}");
|
||||
assert!(content.contains("/Helv 10 Tf"), "in Helvetica 10: {content}");
|
||||
let m = caption_matrix(&content).expect("text matrix");
|
||||
let width = crate::appearance::text_width("Inline 50 mm", 10.0);
|
||||
assert!((m[4] - (172.0 - width / 2.0)).abs() < 0.01 && m[5] < 380.0 && m[5] > 380.0 - 10.0, "centred on the line: {m:?}");
|
||||
let segs = segments(&content);
|
||||
assert_eq!(segs.len(), 2, "two pieces: {segs:?}");
|
||||
assert!(segs.iter().all(|s| s[2] <= 172.0 - width / 2.0 || s[0] >= 172.0 + width / 2.0), "no stroke under the text: {segs:?}");
|
||||
assert!(has(&segs, [72.0, 380.0, 172.0 - width / 2.0 - 2.0, 380.0]), "the left piece starts at the line's start: {segs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_caption_sits_above_the_unbroken_line() {
|
||||
let d = table_178_line(
|
||||
[72.0, 300.0, 272.0, 300.0],
|
||||
&[("Cap", Object::Bool(true)), ("CP", Object::name("Top")), ("Contents", Object::String(PdfString::text("Top 50 mm")))],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
let m = caption_matrix(&content).expect("text matrix");
|
||||
assert!(m[5] > 300.0 && m[5] < 305.0, "baseline just above the line: {m:?}");
|
||||
assert_eq!(segments(&content), vec![[72.0, 300.0, 272.0, 300.0]], "the line is whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caption_offset_moves_it_along_and_off_the_line_with_a_tick() {
|
||||
let co = |h: f64, v: f64| Object::Array(vec![Object::Real(h), Object::Real(v)]);
|
||||
let text = ("Contents", Object::String(PdfString::text("Offset 15")));
|
||||
let base = caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), text.clone()]))).unwrap();
|
||||
let content = line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(0.0, 15.0)), text.clone()]));
|
||||
let up = caption_matrix(&content).unwrap();
|
||||
assert!((up[5] - base[5] - 15.0).abs() < 0.01 && (up[4] - base[4]).abs() < 0.01, "15 up: {base:?} → {up:?}");
|
||||
let segs = segments(&content);
|
||||
assert!(has(&segs, [330.0, 700.0, 530.0, 700.0]), "a caption off the line leaves it whole: {segs:?}");
|
||||
assert!(segs.iter().any(|s| s[0] == 430.0 && s[2] == 430.0 && s[1] == 700.0 && s[3] > 705.0), "a tick from the midpoint up: {segs:?}");
|
||||
let along =
|
||||
caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(-30.0, 0.0)), text])))
|
||||
.unwrap();
|
||||
assert!((along[4] - base[4] + 30.0).abs() < 0.01 && (along[5] - base[5]).abs() < 0.01, "30 left: {base:?} → {along:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_cap_true_no_caption_is_drawn() {
|
||||
for cap in [None, Some(Object::Bool(false)), Some(Object::Int(1))] {
|
||||
let mut extra = vec![("Contents", Object::String(PdfString::text("Hidden caption")))];
|
||||
if let Some(c) = cap.clone() {
|
||||
extra.push(("Cap", c));
|
||||
}
|
||||
let d = table_178_line([330.0, 620.0, 530.0, 620.0], &extra);
|
||||
let content = line_drawing(&d);
|
||||
assert!(!content.contains("Tj"), "{cap:?}: {content}");
|
||||
assert_eq!(segments(&content), vec![[330.0, 620.0, 530.0, 620.0]]);
|
||||
assert!(crate::appearance::undrawable_text(&d).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() {
|
||||
let d = table_178_line(
|
||||
[330.0, 460.0, 530.0, 560.0],
|
||||
&[
|
||||
("LL", Object::Real(20.0)),
|
||||
("LLE", Object::Real(10.0)),
|
||||
("Cap", Object::Bool(true)),
|
||||
("Contents", Object::String(PdfString::text("Diagonal"))),
|
||||
],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
let segs = segments(&content);
|
||||
let (ux, uy) = (200.0 / 100f64.hypot(200.0), 100.0 / 100f64.hypot(200.0));
|
||||
let leaders: Vec<&[f64; 4]> = segs.iter().filter(|s| (s[2] - s[0]) * ux + (s[3] - s[1]) * uy < 1e-2).collect();
|
||||
assert_eq!(leaders.len(), 2, "two leader lines across the line: {segs:?}");
|
||||
for l in leaders {
|
||||
let (dx, dy) = (l[2] - l[0], l[3] - l[1]);
|
||||
assert!((dx.hypot(dy) - 30.0).abs() < 0.01, "|LL| + LLE long: {l:?}");
|
||||
assert!(dx < 0.0 && dy > 0.0, "up and to the left (left of travel): {l:?}");
|
||||
}
|
||||
let m = caption_matrix(&content).expect("caption");
|
||||
assert!((m[0] - ux).abs() < 1e-3 && (m[1] - uy).abs() < 1e-3, "the text runs along the line: {m:?}");
|
||||
// Drawn right to left, it still reads left to right.
|
||||
let back = caption_matrix(&line_drawing(&table_178_line(
|
||||
[530.0, 560.0, 330.0, 460.0],
|
||||
&[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("x")))],
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(back[0] > 0.0, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caption_from_rich_text_and_one_the_font_cant_draw() {
|
||||
let rc = "<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\"><p>Dim & <b>5</b>0 mm <x></p></body>";
|
||||
let d = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("RC", Object::String(PdfString::text(rc)))]);
|
||||
let s = crate::appearance::build(&d).unwrap().decoded().unwrap();
|
||||
assert!(s.windows(17).any(|w| w == b"(Dim & 50 mm <x>)"), "{}", String::from_utf8_lossy(&s));
|
||||
let cjk = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("長さ")))]);
|
||||
assert_eq!(crate::appearance::undrawable_text(&cjk), Some(("the line's caption", '長')));
|
||||
// A PdfCraft measurement's label is its caption: the comment text isn't drawn a second time.
|
||||
let mut measured = cjk.clone();
|
||||
measured.set(b"PCMeasureValue".to_vec(), Object::String(PdfString::text("5 mm")));
|
||||
assert_eq!(crate::appearance::undrawable_text(&measured), None);
|
||||
let content = line_drawing(&measured);
|
||||
assert_eq!(content.matches(" Tj").count(), 1, "{content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
|
||||
let cap = ("Cap", Object::Bool(true));
|
||||
let text = ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))));
|
||||
// A zero-length line has no direction: no leader lines, no caption, no NaN.
|
||||
let point =
|
||||
line_drawing(&table_178_line([50.0, 50.0, 50.0, 50.0], &[("LL", Object::Real(20.0)), ("LLE", Object::Real(5.0)), cap.clone(), text.clone()]));
|
||||
assert!(!point.contains("NaN") && !point.contains("inf") && !point.contains("Tj"), "{point}");
|
||||
assert_eq!(segments(&point).len(), 1);
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MAX] {
|
||||
let d = table_178_line(
|
||||
[0.0, 0.0, 100.0, 0.0],
|
||||
&[
|
||||
("LL", Object::Real(bad)),
|
||||
("LLE", Object::Real(bad)),
|
||||
("LLO", Object::Real(bad)),
|
||||
("CO", Object::Array(vec![Object::Real(bad), Object::Real(bad)])),
|
||||
cap.clone(),
|
||||
text.clone(),
|
||||
],
|
||||
);
|
||||
let content = line_drawing(&d);
|
||||
assert!(!content.contains("NaN") && !content.contains("inf"), "{bad}: {content}");
|
||||
for s in segments(&content) {
|
||||
assert!(s.iter().all(|x| x.abs() <= 50_000.0), "{bad}: {s:?}");
|
||||
}
|
||||
}
|
||||
// Negative LLE and LLO count as 0.
|
||||
let neg = segments(&line_drawing(&table_178_line(
|
||||
[0.0, 0.0, 100.0, 0.0],
|
||||
&[("LL", Object::Real(10.0)), ("LLE", Object::Real(-5.0)), ("LLO", Object::Real(-5.0))],
|
||||
)));
|
||||
assert!(has(&neg, [0.0, 0.0, 0.0, 10.0]) && has(&neg, [0.0, 10.0, 100.0, 10.0]), "{neg:?}");
|
||||
// Huge endpoints whose midpoint would overflow fall back to the plain line.
|
||||
let huge = line_drawing(&table_178_line([f64::MAX, f64::MAX, f64::MAX / 2.0, f64::MAX], &[("LL", Object::Real(20.0)), cap, text]));
|
||||
assert!(!huge.contains("NaN") && !huge.contains("inf"), "{huge}");
|
||||
// The caption is cut to 256 characters.
|
||||
let long = line_drawing(&table_178_line(
|
||||
[0.0, 0.0, 100.0, 0.0],
|
||||
&[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))))],
|
||||
));
|
||||
assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256");
|
||||
assert!(!long.contains(&"w".repeat(257)));
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
use super::create::normalize;
|
||||
use super::*;
|
||||
|
||||
pub type Rgb = [f64; 3];
|
||||
|
||||
/// Text markup kinds (§12.5.6.10).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Markup {
|
||||
Highlight,
|
||||
Underline,
|
||||
StrikeOut,
|
||||
Squiggly,
|
||||
}
|
||||
|
||||
impl Markup {
|
||||
fn subtype(self) -> &'static str {
|
||||
match self {
|
||||
Markup::Highlight => "Highlight",
|
||||
Markup::Underline => "Underline",
|
||||
Markup::StrikeOut => "StrikeOut",
|
||||
Markup::Squiggly => "Squiggly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2
|
||||
/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which
|
||||
/// the same table allows ("Additional names may be supported as well").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum NoteIcon {
|
||||
#[default]
|
||||
Comment,
|
||||
Note,
|
||||
Help,
|
||||
Insert,
|
||||
Key,
|
||||
NewParagraph,
|
||||
Paragraph,
|
||||
Check,
|
||||
Circle,
|
||||
Cross,
|
||||
CrossHairs,
|
||||
Star,
|
||||
RightArrow,
|
||||
UpArrow,
|
||||
}
|
||||
|
||||
impl NoteIcon {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
NoteIcon::Comment => "Comment",
|
||||
NoteIcon::Note => "Note",
|
||||
NoteIcon::Help => "Help",
|
||||
NoteIcon::Insert => "Insert",
|
||||
NoteIcon::Key => "Key",
|
||||
NoteIcon::NewParagraph => "NewParagraph",
|
||||
NoteIcon::Paragraph => "Paragraph",
|
||||
NoteIcon::Check => "Check",
|
||||
NoteIcon::Circle => "Circle",
|
||||
NoteIcon::Cross => "Cross",
|
||||
NoteIcon::CrossHairs => "CrossHairs",
|
||||
NoteIcon::Star => "Star",
|
||||
NoteIcon::RightArrow => "RightArrow",
|
||||
NoteIcon::UpArrow => "UpArrow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every icon, the standard names first.
|
||||
pub const ALL: [NoteIcon; 14] = [
|
||||
Self::Comment,
|
||||
Self::Note,
|
||||
Self::Help,
|
||||
Self::Insert,
|
||||
Self::Key,
|
||||
Self::NewParagraph,
|
||||
Self::Paragraph,
|
||||
Self::Check,
|
||||
Self::Circle,
|
||||
Self::Cross,
|
||||
Self::CrossHairs,
|
||||
Self::Star,
|
||||
Self::RightArrow,
|
||||
Self::UpArrow,
|
||||
];
|
||||
|
||||
pub fn from_name(n: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|i| i.name() == n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FillMark {
|
||||
Check,
|
||||
Cross,
|
||||
Dot,
|
||||
Line,
|
||||
}
|
||||
|
||||
impl FillMark {
|
||||
/// The `/Name` of the stamp PdfCraft draws for it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FillMark::Check => "PCCheck",
|
||||
FillMark::Cross => "PCCross",
|
||||
FillMark::Dot => "PCDot",
|
||||
FillMark::Line => "PCLine",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FillMark::Check => "Checkmark",
|
||||
FillMark::Cross => "Cross",
|
||||
FillMark::Dot => "Dot",
|
||||
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).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum StampKind {
|
||||
// Standard business.
|
||||
Approved,
|
||||
Completed,
|
||||
Confidential,
|
||||
Draft,
|
||||
Final,
|
||||
ForComment,
|
||||
ForPublicRelease,
|
||||
InformationOnly,
|
||||
NotApproved,
|
||||
NotForPublicRelease,
|
||||
PreliminaryResults,
|
||||
Void,
|
||||
// Sign here.
|
||||
Accepted,
|
||||
InitialHere,
|
||||
Rejected,
|
||||
SignHere,
|
||||
Witness,
|
||||
// Dynamic (with a "By … at …" line).
|
||||
DynApproved,
|
||||
DynConfidential,
|
||||
DynReceived,
|
||||
DynReviewed,
|
||||
DynRevised,
|
||||
}
|
||||
|
||||
/// The palette's sections.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StampGroup {
|
||||
Dynamic,
|
||||
SignHere,
|
||||
StandardBusiness,
|
||||
}
|
||||
|
||||
impl StampKind {
|
||||
pub const ALL: [StampKind; 22] = [
|
||||
StampKind::DynApproved,
|
||||
StampKind::DynConfidential,
|
||||
StampKind::DynReceived,
|
||||
StampKind::DynReviewed,
|
||||
StampKind::DynRevised,
|
||||
StampKind::Accepted,
|
||||
StampKind::InitialHere,
|
||||
StampKind::Rejected,
|
||||
StampKind::SignHere,
|
||||
StampKind::Witness,
|
||||
StampKind::Approved,
|
||||
StampKind::Completed,
|
||||
StampKind::Confidential,
|
||||
StampKind::Draft,
|
||||
StampKind::Final,
|
||||
StampKind::ForComment,
|
||||
StampKind::ForPublicRelease,
|
||||
StampKind::InformationOnly,
|
||||
StampKind::NotApproved,
|
||||
StampKind::NotForPublicRelease,
|
||||
StampKind::PreliminaryResults,
|
||||
StampKind::Void,
|
||||
];
|
||||
|
||||
pub fn group(self) -> StampGroup {
|
||||
use StampKind::*;
|
||||
match self {
|
||||
DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic,
|
||||
Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere,
|
||||
_ => StampGroup::StandardBusiness,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text on the stamp.
|
||||
pub fn label(self) -> &'static str {
|
||||
use StampKind::*;
|
||||
match self {
|
||||
Approved | DynApproved => "APPROVED",
|
||||
Completed => "COMPLETED",
|
||||
Confidential | DynConfidential => "CONFIDENTIAL",
|
||||
Draft => "DRAFT",
|
||||
Final => "FINAL",
|
||||
ForComment => "FOR COMMENT",
|
||||
ForPublicRelease => "FOR PUBLIC RELEASE",
|
||||
InformationOnly => "INFORMATION ONLY",
|
||||
NotApproved => "NOT APPROVED",
|
||||
NotForPublicRelease => "NOT FOR PUBLIC RELEASE",
|
||||
PreliminaryResults => "PRELIMINARY RESULTS",
|
||||
Void => "VOID",
|
||||
Accepted => "ACCEPTED",
|
||||
InitialHere => "INITIAL HERE",
|
||||
Rejected => "REJECTED",
|
||||
SignHere => "SIGN HERE",
|
||||
Witness => "WITNESS",
|
||||
DynReceived => "RECEIVED",
|
||||
DynReviewed => "REVIEWED",
|
||||
DynRevised => "REVISED",
|
||||
}
|
||||
}
|
||||
|
||||
/// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists.
|
||||
pub fn name(self) -> &'static str {
|
||||
use StampKind::*;
|
||||
match self {
|
||||
Approved => "Approved",
|
||||
Confidential => "Confidential",
|
||||
Draft => "Draft",
|
||||
Final => "Final",
|
||||
ForComment => "ForComment",
|
||||
ForPublicRelease => "ForPublicRelease",
|
||||
NotApproved => "NotApproved",
|
||||
NotForPublicRelease => "NotForPublicRelease",
|
||||
Completed => "PCCompleted",
|
||||
InformationOnly => "PCInformationOnly",
|
||||
PreliminaryResults => "PCPreliminaryResults",
|
||||
Void => "PCVoid",
|
||||
Accepted => "PCAccepted",
|
||||
InitialHere => "PCInitialHere",
|
||||
Rejected => "PCRejected",
|
||||
SignHere => "PCSignHere",
|
||||
Witness => "PCWitness",
|
||||
DynApproved => "PCDynApproved",
|
||||
DynConfidential => "PCDynConfidential",
|
||||
DynReceived => "PCDynReceived",
|
||||
DynReviewed => "PCDynReviewed",
|
||||
DynRevised => "PCDynRevised",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(n: &[u8]) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|k| k.name().as_bytes() == n)
|
||||
}
|
||||
|
||||
/// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise.
|
||||
pub fn color(self) -> Rgb {
|
||||
use StampKind::*;
|
||||
match self {
|
||||
Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13],
|
||||
NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10],
|
||||
SignHere | InitialHere | Witness => [0.85, 0.35, 0.05],
|
||||
_ => [0.10, 0.30, 0.70],
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamp's size (points) for its label, as placed with a click.
|
||||
pub fn size(self) -> (f64, f64) {
|
||||
let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0;
|
||||
let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 };
|
||||
let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w };
|
||||
(w.max(80.0), h)
|
||||
}
|
||||
}
|
||||
|
||||
/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LineEnding {
|
||||
None,
|
||||
Square,
|
||||
Circle,
|
||||
Diamond,
|
||||
OpenArrow,
|
||||
ClosedArrow,
|
||||
Butt,
|
||||
ROpenArrow,
|
||||
RClosedArrow,
|
||||
Slash,
|
||||
}
|
||||
|
||||
impl LineEnding {
|
||||
pub const ALL: [Self; 10] = [
|
||||
Self::None,
|
||||
Self::Square,
|
||||
Self::Circle,
|
||||
Self::Diamond,
|
||||
Self::OpenArrow,
|
||||
Self::ClosedArrow,
|
||||
Self::Butt,
|
||||
Self::ROpenArrow,
|
||||
Self::RClosedArrow,
|
||||
Self::Slash,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None",
|
||||
Self::Square => "Square",
|
||||
Self::Circle => "Circle",
|
||||
Self::Diamond => "Diamond",
|
||||
Self::OpenArrow => "OpenArrow",
|
||||
Self::ClosedArrow => "ClosedArrow",
|
||||
Self::Butt => "Butt",
|
||||
Self::ROpenArrow => "ROpenArrow",
|
||||
Self::RClosedArrow => "RClosedArrow",
|
||||
Self::Slash => "Slash",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|ending| ending.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry of a new comment, in PDF user space of its page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shape {
|
||||
/// A sticky note whose icon's top-left corner is at `at`.
|
||||
Note {
|
||||
at: [f64; 2],
|
||||
icon: NoteIcon,
|
||||
},
|
||||
/// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right,
|
||||
/// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10).
|
||||
TextMarkup {
|
||||
kind: Markup,
|
||||
quads: Vec<[f64; 8]>,
|
||||
},
|
||||
Rectangle {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
Oval {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
/// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool).
|
||||
Line {
|
||||
from: [f64; 2],
|
||||
to: [f64; 2],
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// Freehand strokes (Draw tool).
|
||||
Ink {
|
||||
strokes: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
/// A text box (FreeText) showing the comment's contents.
|
||||
TextBox {
|
||||
rect: [f64; 4],
|
||||
font_size: f64,
|
||||
},
|
||||
/// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border).
|
||||
Typewriter {
|
||||
rect: [f64; 4],
|
||||
font_size: f64,
|
||||
},
|
||||
/// A Fill & Sign mark in `rect`.
|
||||
Mark {
|
||||
rect: [f64; 4],
|
||||
mark: FillMark,
|
||||
},
|
||||
/// A drawn signature (ink strokes).
|
||||
Signature {
|
||||
strokes: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
/// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line.
|
||||
Stamp {
|
||||
rect: [f64; 4],
|
||||
stamp: StampKind,
|
||||
by: Option<String>,
|
||||
},
|
||||
/// A typed signature or initials: filled outlines (each contour as points normalised to
|
||||
/// `rect`, 0–1, y up), drawn with the non-zero winding rule.
|
||||
TypedSignature {
|
||||
rect: [f64; 4],
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
|
||||
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
|
||||
CustomStamp {
|
||||
rect: [f64; 4],
|
||||
name: String,
|
||||
picture: ObjRef,
|
||||
image: bool,
|
||||
size: (f64, f64),
|
||||
},
|
||||
/// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad
|
||||
/// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`.
|
||||
Redact {
|
||||
quads: Vec<[f64; 8]>,
|
||||
overlay: String,
|
||||
look: OverlayLook,
|
||||
},
|
||||
/// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon
|
||||
/// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`).
|
||||
Polygon {
|
||||
vertices: Vec<[f64; 2]>,
|
||||
cloud: bool,
|
||||
},
|
||||
/// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last.
|
||||
PolyLine {
|
||||
vertices: Vec<[f64; 2]>,
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via
|
||||
/// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`).
|
||||
Callout {
|
||||
rect: [f64; 4],
|
||||
knee: [f64; 2],
|
||||
point: [f64; 2],
|
||||
font_size: f64,
|
||||
ending: LineEnding,
|
||||
},
|
||||
/// Insert text: a caret in `rect` (its point at the top centre).
|
||||
Caret {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
/// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data`
|
||||
/// (embedded) under the name `file`.
|
||||
Attachment {
|
||||
at: [f64; 2],
|
||||
icon: AttachIcon,
|
||||
file: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The standard file attachment icons (§12.5.6.15).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AttachIcon {
|
||||
#[default]
|
||||
PushPin,
|
||||
Paperclip,
|
||||
Graph,
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl AttachIcon {
|
||||
pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
AttachIcon::PushPin => "PushPin",
|
||||
AttachIcon::Paperclip => "Paperclip",
|
||||
AttachIcon::Graph => "Graph",
|
||||
AttachIcon::Tag => "Tag",
|
||||
}
|
||||
}
|
||||
pub fn from_name(n: &str) -> Option<AttachIcon> {
|
||||
Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n))
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
pub fn rect_quad(r: [f64; 4]) -> [f64; 8] {
|
||||
let [x0, y0, x1, y1] = normalize(r);
|
||||
[x0, y1, x1, y1, x0, y0, x1, y0]
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn subtype(&self) -> &'static str {
|
||||
match self {
|
||||
Shape::Note { .. } => "Text",
|
||||
Shape::TextMarkup { kind, .. } => kind.subtype(),
|
||||
Shape::Rectangle { .. } => "Square",
|
||||
Shape::Oval { .. } => "Circle",
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::Ink { .. } | Shape::Signature { .. } => "Ink",
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText",
|
||||
Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp",
|
||||
Shape::Redact { .. } => "Redact",
|
||||
Shape::Polygon { .. } => "Polygon",
|
||||
Shape::PolyLine { .. } => "PolyLine",
|
||||
Shape::Caret { .. } => "Caret",
|
||||
Shape::Attachment { .. } => "FileAttachment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard fonts of redaction overlay text.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum OverlayFont {
|
||||
#[default]
|
||||
Helvetica,
|
||||
Times,
|
||||
Courier,
|
||||
}
|
||||
|
||||
impl OverlayFont {
|
||||
pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
OverlayFont::Helvetica => "Helvetica",
|
||||
OverlayFont::Times => "Times Roman",
|
||||
OverlayFont::Courier => "Courier",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/DA` font resource name (Acrobat's form font names).
|
||||
pub fn resource(self) -> &'static str {
|
||||
match self {
|
||||
OverlayFont::Helvetica => "Helv",
|
||||
OverlayFont::Times => "TiRo",
|
||||
OverlayFont::Courier => "Cour",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_resource(n: &str) -> OverlayFont {
|
||||
match n {
|
||||
"TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times,
|
||||
"Cour" | "Courier" => OverlayFont::Courier,
|
||||
_ => OverlayFont::Helvetica,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OverlayLook {
|
||||
pub font: OverlayFont,
|
||||
/// Points; 0 = auto-size to fit the area.
|
||||
pub size: f64,
|
||||
pub color: Rgb,
|
||||
/// 0 left, 1 centre, 2 right.
|
||||
pub align: u8,
|
||||
/// Repeat the text to fill the area.
|
||||
pub repeat: bool,
|
||||
}
|
||||
|
||||
impl Default for OverlayLook {
|
||||
fn default() -> Self {
|
||||
Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a comment looks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Style {
|
||||
/// Stroke colour (text colour for text boxes, icon colour for notes).
|
||||
pub color: Rgb,
|
||||
/// 0–1 (`/CA`).
|
||||
pub opacity: f64,
|
||||
/// Border / stroke width in points (0 = none).
|
||||
pub width: f64,
|
||||
/// Interior fill for rectangles and ovals (`/IC`), background for text boxes.
|
||||
pub fill: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// The default look of each commenting tool.
|
||||
pub fn default_for(shape: &Shape) -> Self {
|
||||
let (color, width) = match shape {
|
||||
Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0),
|
||||
Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0),
|
||||
Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0),
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0),
|
||||
Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => {
|
||||
([0.89, 0.13, 0.13], 2.0)
|
||||
}
|
||||
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
|
||||
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
Shape::Stamp { stamp, .. } => (stamp.color(), 2.0),
|
||||
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
// Red outline while marked; a black box once applied.
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
|
||||
};
|
||||
Self { color, opacity: 1.0, width, fill: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment to add.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NewAnnotation {
|
||||
/// 0-based page index.
|
||||
pub page: usize,
|
||||
pub shape: Shape,
|
||||
pub style: Style,
|
||||
/// The comment text (`/Contents`); the text shown by a text box.
|
||||
pub contents: String,
|
||||
/// `/T` (shown as the comment's author).
|
||||
pub author: String,
|
||||
}
|
||||
|
||||
/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay
|
||||
/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Meta {
|
||||
pub date: Option<String>,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReviewState {
|
||||
None,
|
||||
Accepted,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl ReviewState {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ReviewState::None => "None",
|
||||
ReviewState::Accepted => "Accepted",
|
||||
ReviewState::Rejected => "Rejected",
|
||||
ReviewState::Cancelled => "Cancelled",
|
||||
ReviewState::Completed => "Completed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(n: &str) -> Option<Self> {
|
||||
[Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n))
|
||||
}
|
||||
}
|
||||
|
||||
// ── building ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Annotation flags (§12.5.3).
|
||||
pub(crate) const FLAG_PRINT: i64 = 4;
|
||||
|
||||
pub(crate) const FLAG_NO_ZOOM: i64 = 8;
|
||||
|
||||
pub(crate) const FLAG_NO_ROTATE: i64 = 16;
|
||||
|
||||
pub(crate) const FLAG_HIDDEN: i64 = 2;
|
||||
|
||||
pub(crate) const FLAG_NO_VIEW: i64 = 32;
|
||||
|
||||
pub(crate) const FLAG_LOCKED: i64 = 128;
|
||||
|
||||
/// Size of a note icon (points, unscaled by zoom).
|
||||
pub const NOTE_SIZE: f64 = 20.0;
|
||||
@@ -17,7 +17,7 @@ pdfcraft-platform = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
base64 = { version = "0.22", optional = true }
|
||||
base64 = { version = "0.23", optional = true }
|
||||
|
||||
|
||||
[features]
|
||||
|
||||
@@ -33,7 +33,7 @@ its undo/redo history.
|
||||
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
|
||||
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
|
||||
- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root <root>: <path>`. It applies on top of `with_root`.
|
||||
- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root <root>: <path>`. As with a root set, on Windows a path on another network share or device path is refused with that refusal without being contacted (#4003). It applies on top of `with_root`.
|
||||
|
||||
## Text search and cache limits
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ use std::collections::BTreeSet;
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
|
||||
|
||||
mod documents;
|
||||
mod form_tools;
|
||||
mod ocr;
|
||||
|
||||
fn status_id(s: Status) -> &'static str {
|
||||
match s {
|
||||
Status::Passed => "passed",
|
||||
@@ -574,511 +578,6 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
|
||||
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
|
||||
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
|
||||
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
|
||||
if let Some(d) = a.opt_num("dpi")? {
|
||||
settings.dpi = d.clamp(72.0, 600.0) as f32;
|
||||
}
|
||||
if let Some(l) = a.opt_str("language")? {
|
||||
if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) {
|
||||
return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}")));
|
||||
}
|
||||
settings.language = l.into();
|
||||
}
|
||||
if let Some(s) = a.opt_bool("skip_text_pages")? {
|
||||
settings.skip_text_pages = s;
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Recognizes text in each of `paths` and writes the result under the same file name in
|
||||
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
|
||||
/// path outside the root refuses the whole call before any work starts.
|
||||
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
// A path that leaves the root refuses the whole call before any work starts, like the
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
// Two inputs that would write the same output file overwrite each other while both report
|
||||
// success, so the whole batch is refused here: before the folder is created, anything is
|
||||
// read, or the OCR engines load.
|
||||
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
|
||||
let shared = shared_file_names(&targets);
|
||||
if !shared.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
|
||||
shared.join(", ")
|
||||
)));
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let name = input_file_name(p);
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|
||||
);
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
let skipped: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
|
||||
/// reporting the total word count and, per page, why it was skipped or what it found.
|
||||
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let doc = self.doc(a)?;
|
||||
// Recognizing needs only permission to change the document; the words it found are
|
||||
// returned only when copying is allowed too.
|
||||
let (id, copy) = (doc.id, doc.allows_copying());
|
||||
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|p| match &p.skipped {
|
||||
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
|
||||
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
|
||||
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
|
||||
}
|
||||
|
||||
/// Whether text recognition's models are installed, the directories searched for them (in
|
||||
/// order), and the languages it reads, each as a code and a name.
|
||||
pub(crate) fn ocr_status(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::ocr;
|
||||
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
|
||||
let langs: Vec<Value> = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect();
|
||||
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name an input is written under: its own file name, or `document.pdf` when it has none.
|
||||
fn input_file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into())
|
||||
}
|
||||
|
||||
/// The output file names that two or more of `targets` share, each spelling listed once, in input
|
||||
/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on
|
||||
/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode
|
||||
/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are
|
||||
/// not caught. The folder is the same for every target, so only the names are compared.
|
||||
fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec<String> {
|
||||
let mut groups: std::collections::BTreeMap<String, (usize, Vec<String>)> = std::collections::BTreeMap::new();
|
||||
for target in targets {
|
||||
let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let key = if cfg!(windows) {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Case only; NFC and NFD spellings of one name stay distinct here (a known limit).
|
||||
name.to_lowercase()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new()));
|
||||
*count += 1;
|
||||
if !spellings.contains(&name) {
|
||||
spellings.push(name);
|
||||
}
|
||||
}
|
||||
groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect()
|
||||
}
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
R::Reset(n) => json!({ "reset": n }),
|
||||
R::Print => json!({ "print": true }),
|
||||
R::GoToPage(p) => json!({ "page": p + 1 }),
|
||||
R::LaunchUrl(u) => json!({ "url": u }),
|
||||
R::Submit(u) => json!({ "submit": u }),
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
let list: Vec<Value> = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect();
|
||||
Ok(json!({ "scripts": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.js_document_scripts(a)
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
event: a.str("event")?.into(),
|
||||
script: a.opt_str("script")?.map(str::to_string),
|
||||
};
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
}
|
||||
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
|
||||
let target = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&target, csv.as_bytes())?;
|
||||
let columns = csv.lines().next().map_or(0, |h| h.split(',').count());
|
||||
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
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 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> {
|
||||
let l = a.opt_str("level")?.unwrap_or("2b");
|
||||
pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)")))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_verify(&self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
let issues: Vec<Value> = doc
|
||||
.pdfa_verify(level)
|
||||
.iter()
|
||||
.map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable }))
|
||||
.collect();
|
||||
let d = doc.standards();
|
||||
Ok(json!({
|
||||
"level": level.label(),
|
||||
"compliant": issues.is_empty(),
|
||||
"issues": issues,
|
||||
"declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents },
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
|
||||
let report = self.doc(a)?.print_readiness();
|
||||
let findings: Vec<Value> =
|
||||
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
|
||||
let pages: Vec<Value> = report
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"page": p.page,
|
||||
"media_mm": p.media_mm,
|
||||
"crop_mm": p.crop_mm,
|
||||
"trim_mm": p.trim_mm,
|
||||
"bleed_mm": p.bleed_mm,
|
||||
"trim_set": p.trim_set,
|
||||
"bleed_set": p.bleed_set,
|
||||
"bleed_margin_mm": p.bleed_margin_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let images: Vec<Value> = report
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
json!({
|
||||
"page": image.page,
|
||||
"name": image.name,
|
||||
"ppi": image.ppi,
|
||||
"pixels": image.pixels,
|
||||
"size_mm": image.size_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pdfa: Vec<Value> = report
|
||||
.pdfa_issues
|
||||
.iter()
|
||||
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"text": report.text(),
|
||||
"findings": findings,
|
||||
"pages": pages,
|
||||
"images": images,
|
||||
"output_intents": report.output_intents,
|
||||
"fonts_not_embedded": report.fonts_not_embedded,
|
||||
"pdfa_issues": pdfa,
|
||||
"resolution_unknown": report.resolution_unknown,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
|
||||
self.pdfa_verify(a)
|
||||
}
|
||||
|
||||
pub(crate) fn action_list(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::actions::{Step, builtin};
|
||||
let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() });
|
||||
let actions: Vec<Value> = builtin()
|
||||
.iter()
|
||||
.map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
let steps: Vec<Value> = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect();
|
||||
Ok(json!({ "actions": actions, "steps": steps }))
|
||||
}
|
||||
|
||||
pub(crate) fn action_run(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::actions::{Action, Step, builtin, run_on};
|
||||
let action = if let Some(name) = a.opt_str("action")? {
|
||||
builtin()
|
||||
.into_iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))?
|
||||
} else {
|
||||
let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?;
|
||||
let steps = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let id = it["step"].as_str().unwrap_or("");
|
||||
Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}")))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Action { name: "Custom".into(), description: String::new(), steps, builtin: false }
|
||||
};
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
|
||||
let result = self
|
||||
.resolve(p, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
let target = if target.exists() {
|
||||
let stem =
|
||||
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
unused(&folder, &stem)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if target.exists() {
|
||||
return Err(failed(format!("{}: no unused output name available", target.display())));
|
||||
}
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "action": action.name, "files": out }))
|
||||
}
|
||||
|
||||
fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> {
|
||||
let new = self.doc(a)?.id;
|
||||
let other = a.int("other")?;
|
||||
let old = u64::try_from(other)
|
||||
.ok()
|
||||
.and_then(|o| self.session.get(pdfcraft_engine::DocId(o)))
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?;
|
||||
Ok((old, new))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::compare::Kind;
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let c = self.session.compare(old, new).map_err(failed)?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize;
|
||||
let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0);
|
||||
let side =
|
||||
|s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::<Vec<_>>() });
|
||||
let list: Vec<Value> = c
|
||||
.changes
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) }))
|
||||
.collect();
|
||||
let visual: Vec<Value> = if a.opt_bool("visual")?.unwrap_or(false) {
|
||||
self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(json!({
|
||||
"visual": visual,
|
||||
"identical": c.identical(),
|
||||
"replaced": c.count(Kind::Replaced),
|
||||
"inserted": c.count(Kind::Inserted),
|
||||
"deleted": c.count(Kind::Deleted),
|
||||
"old_words": c.old_words,
|
||||
"new_words": c.new_words,
|
||||
"changes": list,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_report(&self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let bytes = self.session.compare_report(old, new).map_err(failed)?;
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let n = self.session.mark_differences(old, new).map_err(failed)?;
|
||||
Ok(json!({ "comments": n }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.detect_fields(id, &pages);
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
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)),
|
||||
};
|
||||
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() {
|
||||
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
|
||||
}
|
||||
Ok(json!({ "fields": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::FieldAction as A;
|
||||
let list: Vec<Value> = self
|
||||
.doc(a)?
|
||||
.field_actions(a.str("field")?)
|
||||
.iter()
|
||||
.map(|(t, act)| {
|
||||
let mut v = match act {
|
||||
A::JavaScript(j) => json!({ "javascript": j }),
|
||||
A::Uri(u) => json!({ "url": u }),
|
||||
A::Reset(f) => json!({ "reset": f }),
|
||||
A::Named(n) => json!({ "menu": n }),
|
||||
A::GoTo(p) => json!({ "page": p + 1 }),
|
||||
A::ShowHide { fields, hide: true } => json!({ "hide": fields }),
|
||||
A::ShowHide { fields, hide: false } => json!({ "show": fields }),
|
||||
A::Submit(u) => json!({ "submit": u }),
|
||||
A::Other(s) => json!({ "other": s }),
|
||||
};
|
||||
v["trigger"] = json!(t.id());
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "field": a.str("field")?, "actions": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{FieldAction as A, FieldTrigger as T};
|
||||
let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?;
|
||||
let bad = |m: String| ToolError::InvalidArgs(m);
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut acts = Vec::new();
|
||||
for it in items {
|
||||
let tid = it["trigger"].as_str().unwrap_or("");
|
||||
let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?;
|
||||
let act = if let Some(j) = it["javascript"].as_str() {
|
||||
A::JavaScript(j.into())
|
||||
} else if let Some(u) = it["url"].as_str() {
|
||||
A::Uri(u.into())
|
||||
} else if it.get("reset").is_some() {
|
||||
A::Reset(names(&it["reset"]))
|
||||
} else if let Some(m) = it["menu"].as_str() {
|
||||
A::Named(m.into())
|
||||
} else if let Some(p) = it["page"].as_u64() {
|
||||
A::GoTo((p.max(1) - 1) as usize)
|
||||
} else if it.get("show").is_some() {
|
||||
A::ShowHide { fields: names(&it["show"]), hide: false }
|
||||
} else if it.get("hide").is_some() {
|
||||
A::ShowHide { fields: names(&it["hide"]), hide: true }
|
||||
} else if let Some(u) = it["submit"].as_str() {
|
||||
A::Submit(u.into())
|
||||
} else {
|
||||
return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)")));
|
||||
};
|
||||
acts.push((t, act));
|
||||
}
|
||||
let name = a.str("field")?.to_string();
|
||||
let edit =
|
||||
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.form_actions(a)
|
||||
}
|
||||
|
||||
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
|
||||
if let Some(on) = a.opt_bool("enabled")? {
|
||||
self.session.set_javascript(on);
|
||||
}
|
||||
Ok(json!({ "enabled": self.session.javascript() }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_html_tests {
|
||||
use super::*;
|
||||
@@ -1098,21 +597,3 @@ mod review_html_tests {
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_output_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_output_names_follow_the_default_filesystem() {
|
||||
let targets = |names: &[&str]| -> Vec<std::path::PathBuf> { names.iter().map(|n| std::path::Path::new("out").join(n)).collect() };
|
||||
// A spelling that repeats is one shared name, listed once.
|
||||
assert_eq!(shared_file_names(&targets(&["a.pdf", "b.pdf", "a.pdf"])), vec!["a.pdf".to_string()]);
|
||||
assert!(shared_file_names(&targets(&["a.pdf", "b.pdf"])).is_empty());
|
||||
// Case is ignored on Windows and macOS only; a trailing dot is ignored on Windows only.
|
||||
let case = shared_file_names(&targets(&["scan.pdf", "Scan.PDF"]));
|
||||
let dot = shared_file_names(&targets(&["scan.pdf", "scan.pdf."]));
|
||||
assert_eq!(case.len(), if cfg!(any(windows, target_os = "macos")) { 2 } else { 0 }, "{case:?}");
|
||||
assert_eq!(dot.len(), if cfg!(windows) { 2 } else { 0 }, "{dot:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use super::*;
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
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 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> {
|
||||
let l = a.opt_str("level")?.unwrap_or("2b");
|
||||
pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)")))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_verify(&self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
let issues: Vec<Value> = doc
|
||||
.pdfa_verify(level)
|
||||
.iter()
|
||||
.map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable }))
|
||||
.collect();
|
||||
let d = doc.standards();
|
||||
Ok(json!({
|
||||
"level": level.label(),
|
||||
"compliant": issues.is_empty(),
|
||||
"issues": issues,
|
||||
"declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents },
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
|
||||
let report = self.doc(a)?.print_readiness();
|
||||
let findings: Vec<Value> =
|
||||
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
|
||||
let pages: Vec<Value> = report
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"page": p.page,
|
||||
"media_mm": p.media_mm,
|
||||
"crop_mm": p.crop_mm,
|
||||
"trim_mm": p.trim_mm,
|
||||
"bleed_mm": p.bleed_mm,
|
||||
"trim_set": p.trim_set,
|
||||
"bleed_set": p.bleed_set,
|
||||
"bleed_margin_mm": p.bleed_margin_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let images: Vec<Value> = report
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
json!({
|
||||
"page": image.page,
|
||||
"name": image.name,
|
||||
"ppi": image.ppi,
|
||||
"pixels": image.pixels,
|
||||
"size_mm": image.size_mm,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pdfa: Vec<Value> = report
|
||||
.pdfa_issues
|
||||
.iter()
|
||||
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"text": report.text(),
|
||||
"findings": findings,
|
||||
"pages": pages,
|
||||
"images": images,
|
||||
"output_intents": report.output_intents,
|
||||
"fonts_not_embedded": report.fonts_not_embedded,
|
||||
"pdfa_issues": pdfa,
|
||||
"resolution_unknown": report.resolution_unknown,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
|
||||
let level = self.pdfa_level(a)?;
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
|
||||
self.pdfa_verify(a)
|
||||
}
|
||||
|
||||
pub(crate) fn action_list(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::actions::{Step, builtin};
|
||||
let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() });
|
||||
let actions: Vec<Value> = builtin()
|
||||
.iter()
|
||||
.map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
let steps: Vec<Value> = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect();
|
||||
Ok(json!({ "actions": actions, "steps": steps }))
|
||||
}
|
||||
|
||||
pub(crate) fn action_run(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::actions::{Action, Step, builtin, run_on};
|
||||
let action = if let Some(name) = a.opt_str("action")? {
|
||||
builtin()
|
||||
.into_iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))?
|
||||
} else {
|
||||
let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?;
|
||||
let steps = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let id = it["step"].as_str().unwrap_or("");
|
||||
Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}")))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Action { name: "Custom".into(), description: String::new(), steps, builtin: false }
|
||||
};
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
|
||||
let result = self
|
||||
.resolve(p, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
let target = if target.exists() {
|
||||
let stem =
|
||||
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
unused(&folder, &stem)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if target.exists() {
|
||||
return Err(failed(format!("{}: no unused output name available", target.display())));
|
||||
}
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "action": action.name, "files": out }))
|
||||
}
|
||||
|
||||
fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> {
|
||||
let new = self.doc(a)?.id;
|
||||
let other = a.int("other")?;
|
||||
let old = u64::try_from(other)
|
||||
.ok()
|
||||
.and_then(|o| self.session.get(pdfcraft_engine::DocId(o)))
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?;
|
||||
Ok((old, new))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::compare::Kind;
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let c = self.session.compare(old, new).map_err(failed)?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize;
|
||||
let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0);
|
||||
let side =
|
||||
|s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::<Vec<_>>() });
|
||||
let list: Vec<Value> = c
|
||||
.changes
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) }))
|
||||
.collect();
|
||||
let visual: Vec<Value> = if a.opt_bool("visual")?.unwrap_or(false) {
|
||||
self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(json!({
|
||||
"visual": visual,
|
||||
"identical": c.identical(),
|
||||
"replaced": c.count(Kind::Replaced),
|
||||
"inserted": c.count(Kind::Inserted),
|
||||
"deleted": c.count(Kind::Deleted),
|
||||
"old_words": c.old_words,
|
||||
"new_words": c.new_words,
|
||||
"changes": list,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_report(&self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let bytes = self.session.compare_report(old, new).map_err(failed)?;
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
|
||||
let (old, new) = self.compare_ids(a)?;
|
||||
let n = self.session.mark_differences(old, new).map_err(failed)?;
|
||||
Ok(json!({ "comments": n }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
R::Reset(n) => json!({ "reset": n }),
|
||||
R::Print => json!({ "print": true }),
|
||||
R::GoToPage(p) => json!({ "page": p + 1 }),
|
||||
R::LaunchUrl(u) => json!({ "url": u }),
|
||||
R::Submit(u) => json!({ "submit": u }),
|
||||
R::Focus(f) => json!({ "focus": f }),
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
let list: Vec<Value> = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect();
|
||||
Ok(json!({ "scripts": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.js_document_scripts(a)
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
event: a.str("event")?.into(),
|
||||
script: a.opt_str("script")?.map(str::to_string),
|
||||
};
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
|
||||
}
|
||||
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
|
||||
let target = self.resolve(a.str("path")?, true)?;
|
||||
write_atomic(&target, csv.as_bytes())?;
|
||||
let columns = csv.lines().next().map_or(0, |h| h.split(',').count());
|
||||
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.detect_fields(id, &pages);
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
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)),
|
||||
};
|
||||
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() {
|
||||
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
|
||||
}
|
||||
Ok(json!({ "fields": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::FieldAction as A;
|
||||
let list: Vec<Value> = self
|
||||
.doc(a)?
|
||||
.field_actions(a.str("field")?)
|
||||
.iter()
|
||||
.map(|(t, act)| {
|
||||
let mut v = match act {
|
||||
A::JavaScript(j) => json!({ "javascript": j }),
|
||||
A::Uri(u) => json!({ "url": u }),
|
||||
A::Reset(f) => json!({ "reset": f }),
|
||||
A::Named(n) => json!({ "menu": n }),
|
||||
A::GoTo(p) => json!({ "page": p + 1 }),
|
||||
A::ShowHide { fields, hide: true } => json!({ "hide": fields }),
|
||||
A::ShowHide { fields, hide: false } => json!({ "show": fields }),
|
||||
A::Submit(u) => json!({ "submit": u }),
|
||||
A::Other(s) => json!({ "other": s }),
|
||||
};
|
||||
v["trigger"] = json!(t.id());
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "field": a.str("field")?, "actions": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{FieldAction as A, FieldTrigger as T};
|
||||
let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?;
|
||||
let bad = |m: String| ToolError::InvalidArgs(m);
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut acts = Vec::new();
|
||||
for it in items {
|
||||
let tid = it["trigger"].as_str().unwrap_or("");
|
||||
let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?;
|
||||
let act = if let Some(j) = it["javascript"].as_str() {
|
||||
A::JavaScript(j.into())
|
||||
} else if let Some(u) = it["url"].as_str() {
|
||||
A::Uri(u.into())
|
||||
} else if it.get("reset").is_some() {
|
||||
A::Reset(names(&it["reset"]))
|
||||
} else if let Some(m) = it["menu"].as_str() {
|
||||
A::Named(m.into())
|
||||
} else if let Some(p) = it["page"].as_u64() {
|
||||
A::GoTo((p.max(1) - 1) as usize)
|
||||
} else if it.get("show").is_some() {
|
||||
A::ShowHide { fields: names(&it["show"]), hide: false }
|
||||
} else if it.get("hide").is_some() {
|
||||
A::ShowHide { fields: names(&it["hide"]), hide: true }
|
||||
} else if let Some(u) = it["submit"].as_str() {
|
||||
A::Submit(u.into())
|
||||
} else {
|
||||
return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)")));
|
||||
};
|
||||
acts.push((t, act));
|
||||
}
|
||||
let name = a.str("field")?.to_string();
|
||||
let edit =
|
||||
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.form_actions(a)
|
||||
}
|
||||
|
||||
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
|
||||
if let Some(on) = a.opt_bool("enabled")? {
|
||||
self.session.set_javascript(on);
|
||||
}
|
||||
Ok(json!({ "enabled": self.session.javascript() }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
|
||||
impl Automation {
|
||||
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
|
||||
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
|
||||
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
|
||||
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
|
||||
if let Some(d) = a.opt_num("dpi")? {
|
||||
settings.dpi = d.clamp(72.0, 600.0) as f32;
|
||||
}
|
||||
if let Some(l) = a.opt_str("language")? {
|
||||
if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) {
|
||||
return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}")));
|
||||
}
|
||||
settings.language = l.into();
|
||||
}
|
||||
if let Some(s) = a.opt_bool("skip_text_pages")? {
|
||||
settings.skip_text_pages = s;
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Recognizes text in each of `paths` and writes the result under the same file name in
|
||||
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
|
||||
/// path outside the root refuses the whole call before any work starts.
|
||||
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
// A path that leaves the root refuses the whole call before any work starts, like the
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
// Two inputs that would write the same output file overwrite each other while both report
|
||||
// success, so the whole batch is refused here: before the folder is created, anything is
|
||||
// read, or the OCR engines load.
|
||||
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
|
||||
let shared = shared_file_names(&targets);
|
||||
if !shared.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
|
||||
shared.join(", ")
|
||||
)));
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let name = input_file_name(p);
|
||||
let result =
|
||||
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|
||||
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|
||||
);
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
let skipped: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
|
||||
/// reporting the total word count and, per page, why it was skipped or what it found.
|
||||
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
|
||||
let settings = self.ocr_settings(a)?;
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let doc = self.doc(a)?;
|
||||
// Recognizing needs only permission to change the document; the words it found are
|
||||
// returned only when copying is allowed too.
|
||||
let (id, copy) = (doc.id, doc.allows_copying());
|
||||
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|p| match &p.skipped {
|
||||
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
|
||||
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
|
||||
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
|
||||
}
|
||||
|
||||
/// Whether text recognition's models are installed, the directories searched for them (in
|
||||
/// order), and the languages it reads, each as a code and a name.
|
||||
pub(crate) fn ocr_status(&self) -> Result<Value> {
|
||||
use pdfcraft_engine::ocr;
|
||||
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
|
||||
let langs: Vec<Value> = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect();
|
||||
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name an input is written under: its own file name, or `document.pdf` when it has none.
|
||||
fn input_file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into())
|
||||
}
|
||||
|
||||
/// The output file names that two or more of `targets` share, each spelling listed once, in input
|
||||
/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on
|
||||
/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode
|
||||
/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are
|
||||
/// not caught. The folder is the same for every target, so only the names are compared.
|
||||
fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec<String> {
|
||||
let mut groups: std::collections::BTreeMap<String, (usize, Vec<String>)> = std::collections::BTreeMap::new();
|
||||
for target in targets {
|
||||
let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let key = if cfg!(windows) {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Case only; NFC and NFD spellings of one name stay distinct here (a known limit).
|
||||
name.to_lowercase()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new()));
|
||||
*count += 1;
|
||||
if !spellings.contains(&name) {
|
||||
spellings.push(name);
|
||||
}
|
||||
}
|
||||
groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_output_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_output_names_follow_the_default_filesystem() {
|
||||
let targets = |names: &[&str]| -> Vec<std::path::PathBuf> { names.iter().map(|n| std::path::Path::new("out").join(n)).collect() };
|
||||
// A spelling that repeats is one shared name, listed once.
|
||||
assert_eq!(shared_file_names(&targets(&["a.pdf", "b.pdf", "a.pdf"])), vec!["a.pdf".to_string()]);
|
||||
assert!(shared_file_names(&targets(&["a.pdf", "b.pdf"])).is_empty());
|
||||
// Case is ignored on Windows and macOS only; a trailing dot is ignored on Windows only.
|
||||
let case = shared_file_names(&targets(&["scan.pdf", "Scan.PDF"]));
|
||||
let dot = shared_file_names(&targets(&["scan.pdf", "scan.pdf."]));
|
||||
assert_eq!(case.len(), if cfg!(any(windows, target_os = "macos")) { 2 } else { 0 }, "{case:?}");
|
||||
assert_eq!(dot.len(), if cfg!(windows) { 2 } else { 0 }, "{dot:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, one_based, summary};
|
||||
use super::paths::{child, read_input, read_input_bytes, unused, write_atomic};
|
||||
use super::*;
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_create(&mut self, a: &Args) -> Result<Value> {
|
||||
let (name, bytes) = match a.str("from")? {
|
||||
"blank" => {
|
||||
let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize;
|
||||
let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0));
|
||||
("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?)
|
||||
}
|
||||
"images" => {
|
||||
let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi);
|
||||
let mut images = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes));
|
||||
}
|
||||
let name = if images.len() == 1 {
|
||||
format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s))
|
||||
} else {
|
||||
"Images.pdf".into()
|
||||
};
|
||||
(name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?)
|
||||
}
|
||||
"text" => {
|
||||
let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) {
|
||||
(Some(t), _) => ("Text".to_string(), t.to_string()),
|
||||
(None, Some(p)) => {
|
||||
let path = self.resolve(p, false)?;
|
||||
let t = read_input(&path)?;
|
||||
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t))
|
||||
}
|
||||
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
|
||||
};
|
||||
// The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with
|
||||
// its line) by `pdfcraft_create::from_text`, as for every other caller.
|
||||
let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e {
|
||||
pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m),
|
||||
e => failed(e),
|
||||
})?;
|
||||
(format!("{title}.pdf"), bytes)
|
||||
}
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))),
|
||||
};
|
||||
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name);
|
||||
let id = self.session.open_new(name, bytes).map_err(failed)?;
|
||||
Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?))
|
||||
}
|
||||
|
||||
pub(crate) fn page_transition_set(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{Transition, TransitionSpec};
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let transition = match a.opt_str("style")? {
|
||||
None | Some("none") => None,
|
||||
Some(style) => {
|
||||
let mut t = Transition::new(style);
|
||||
if let Some(d) = a.opt_num("duration")? {
|
||||
t.duration = d as f32;
|
||||
}
|
||||
if let Some(dm) = a.opt_str("dimension")? {
|
||||
t.horizontal = match dm {
|
||||
"horizontal" => true,
|
||||
"vertical" => false,
|
||||
other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))),
|
||||
};
|
||||
}
|
||||
if let Some(m) = a.opt_str("motion")? {
|
||||
t.inward = match m {
|
||||
"inward" => true,
|
||||
"outward" => false,
|
||||
other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))),
|
||||
};
|
||||
}
|
||||
match a.get("direction") {
|
||||
None => {}
|
||||
Some(Value::String(s)) if s == "none" => t.direction = None,
|
||||
Some(v) => {
|
||||
t.direction = Some(
|
||||
v.as_u64()
|
||||
.and_then(|d| u16::try_from(d).ok())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(s) = a.opt_num("scale")? {
|
||||
t.scale = s as f32;
|
||||
}
|
||||
if let Some(b) = a.opt_bool("opaque")? {
|
||||
t.opaque = b;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let display = a.opt_num("display")?.map(|s| s as f32);
|
||||
self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } })
|
||||
}
|
||||
|
||||
pub(crate) fn page_set_box(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{BoxSpec, PageBox};
|
||||
let doc = self.doc(a)?;
|
||||
let n = doc.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let which = match a.opt_str("box")? {
|
||||
None => PageBox::Crop,
|
||||
Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?,
|
||||
};
|
||||
let nums = |key: &str| -> Result<Option<[f64; 4]>> {
|
||||
a.get(key)
|
||||
.map(|v| {
|
||||
let v: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
<[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers")))
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
let spec = match (nums("margins")?, nums("rect")?) {
|
||||
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())),
|
||||
(Some(m), None) => BoxSpec::Margins(m),
|
||||
(None, Some(r)) => {
|
||||
// Top-left-origin points on the displayed page → user space (one page at a time).
|
||||
if pages.len() != 1 {
|
||||
return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into()));
|
||||
}
|
||||
let p = &doc.info.pages[pages[0]];
|
||||
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));
|
||||
BoxSpec::Rect([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])
|
||||
}
|
||||
(None, None) => BoxSpec::Remove,
|
||||
};
|
||||
let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?;
|
||||
out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::<Vec<_>>());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_blank(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let at = self.position(a, "at")?;
|
||||
// Default to the size of the neighbouring page, as Acrobat does; Letter for empty files.
|
||||
let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first());
|
||||
let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0));
|
||||
let width = a.opt_num("width")?.unwrap_or(w);
|
||||
let height = a.opt_num("height")?.unwrap_or(h);
|
||||
if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) {
|
||||
return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into()));
|
||||
}
|
||||
self.apply(a, Edit::InsertBlankPage { at, width, height })
|
||||
}
|
||||
|
||||
pub(crate) fn insert_file(&mut self, a: &Args) -> Result<Value> {
|
||||
let at = self.position(a, "at")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
|
||||
}
|
||||
|
||||
/// Write freshly created bytes to `out` and/or open them as a new document.
|
||||
pub(crate) fn deliver(&mut self, a: &Args, name: &str, bytes: Arc<Vec<u8>>) -> Result<Value> {
|
||||
let mut result = json!({ "bytes": bytes.len() });
|
||||
let out = a.opt_str("out")?;
|
||||
if let Some(out) = out {
|
||||
let path = self.resolve(out, true)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
result["path"] = json!(path.to_string_lossy());
|
||||
}
|
||||
if a.opt_bool("open")?.unwrap_or(out.is_none()) {
|
||||
let id = self.session.open_new(name, bytes).map_err(failed)?;
|
||||
result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_create_multiple(&mut self, a: &Args) -> Result<Value> {
|
||||
let paths = a.strs("paths")?;
|
||||
if paths.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("paths must list at least one file".into()));
|
||||
}
|
||||
let separate = match a.opt_str("mode")? {
|
||||
None | Some("combine") => false,
|
||||
Some("separate") => true,
|
||||
Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))),
|
||||
};
|
||||
if separate {
|
||||
return self.create_separate(a, &paths);
|
||||
}
|
||||
let ranges: Vec<Option<String>> = match a.get("pages") {
|
||||
None | Some(Value::Null) => vec![None; paths.len()],
|
||||
Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(),
|
||||
Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())),
|
||||
};
|
||||
// Each file is read, converted and copied in turn, so a long list holds one file at a time.
|
||||
let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result<pdfcraft_engine::CombineSource, ToolError> {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?;
|
||||
let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok((title, pdf, range))
|
||||
});
|
||||
let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?;
|
||||
self.deliver(a, "Combined", bytes)
|
||||
}
|
||||
|
||||
/// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others.
|
||||
fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result<Value> {
|
||||
let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let mut out = Vec::new();
|
||||
for &p in paths {
|
||||
let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| {
|
||||
let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?;
|
||||
Ok((kind, stem, pdf))
|
||||
});
|
||||
out.push(match converted {
|
||||
Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }),
|
||||
Ok((_, stem, pdf)) => {
|
||||
let target = unused(&dir, &stem);
|
||||
write_atomic(&target, &pdf)?;
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
});
|
||||
}
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
pub(crate) fn page_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, name) = (doc.id, format!("{} (extract)", doc.name));
|
||||
let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into());
|
||||
let pages = self.pages(a, "pages")?;
|
||||
let mut out = if a.opt_bool("separate")?.unwrap_or(false) {
|
||||
// Each page as its own file.
|
||||
let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let mut files = Vec::new();
|
||||
for &p in &pages {
|
||||
let bytes = self.session.extract(id, &[p]).map_err(failed)?;
|
||||
let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1));
|
||||
write_atomic(&path, &bytes)?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
json!({ "files": files })
|
||||
} else {
|
||||
let bytes = self.session.extract(id, &pages).map_err(failed)?;
|
||||
self.deliver(a, &name, bytes)?
|
||||
};
|
||||
if a.opt_bool("delete")?.unwrap_or(false) {
|
||||
let deleted = self.apply(a, Edit::DeletePages { pages })?;
|
||||
out["original"] = deleted;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_combine(&mut self, a: &Args) -> Result<Value> {
|
||||
let paths = a.strs("paths")?;
|
||||
if paths.len() < 2 {
|
||||
return Err(ToolError::InvalidArgs("combine needs at least two files".into()));
|
||||
}
|
||||
let ranges: Vec<Option<String>> = match a.get("pages") {
|
||||
None | Some(Value::Null) => vec![None; paths.len()],
|
||||
Some(Value::Array(v)) if v.len() == paths.len() => v
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(range) => Ok(Some(range.clone())),
|
||||
_ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))),
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())),
|
||||
};
|
||||
let passwords: Vec<Option<String>> = match a.get("passwords") {
|
||||
None | Some(Value::Null) => vec![None; paths.len()],
|
||||
Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => {
|
||||
v.iter().map(|x| x.as_str().map(str::to_owned)).collect()
|
||||
}
|
||||
Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())),
|
||||
};
|
||||
// Entries sharing a group number are one file split into parts placed apart.
|
||||
let groups: Option<Vec<Option<u64>>> = match a.get("groups") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => {
|
||||
Some(v.iter().map(Value::as_u64).collect())
|
||||
}
|
||||
Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())),
|
||||
};
|
||||
let field_conflicts = match a.opt_str("field_conflicts")? {
|
||||
None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename,
|
||||
Some("merge") => pdfcraft_organize::FieldConflicts::Merge,
|
||||
Some(other) => {
|
||||
return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}")));
|
||||
}
|
||||
};
|
||||
let mut sources: Vec<CombineSource> = Vec::new();
|
||||
let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new();
|
||||
for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() {
|
||||
let path = self.resolve(p, false)?;
|
||||
let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten());
|
||||
let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g));
|
||||
let bytes = match first {
|
||||
Some((g, first_path, at)) => {
|
||||
if *first_path != path {
|
||||
return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display())));
|
||||
}
|
||||
sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))?
|
||||
}
|
||||
None => {
|
||||
if let Some(g) = group {
|
||||
firsts.push((g, path.clone(), sources.len()));
|
||||
}
|
||||
let bytes = Arc::new(read_input(&path)?);
|
||||
// Images and text files are made into PDFs first (#569), as in the Combine files tab.
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.session.as_pdf(&file_name, &bytes).map_err(failed)?
|
||||
}
|
||||
};
|
||||
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
sources.push((name, bytes, range));
|
||||
}
|
||||
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
|
||||
let combined = match groups {
|
||||
None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts),
|
||||
Some(groups) => {
|
||||
// A path without a group is a file of its own: give it a key no group uses.
|
||||
let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1));
|
||||
let mut keys = Vec::with_capacity(groups.len());
|
||||
for g in &groups {
|
||||
keys.push(match g {
|
||||
Some(g) => *g,
|
||||
None => {
|
||||
let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?;
|
||||
next = own.checked_add(1);
|
||||
own
|
||||
}
|
||||
});
|
||||
}
|
||||
self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts)
|
||||
}
|
||||
}
|
||||
.map_err(failed)?;
|
||||
let mut result = self.deliver(a, "Combined", combined.bytes)?;
|
||||
if !combined.renamed_fields.is_empty() {
|
||||
result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_split(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into());
|
||||
let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false);
|
||||
let max_mb = a.opt_num("max_mb")?;
|
||||
let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count();
|
||||
if chosen != 1 {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(),
|
||||
));
|
||||
}
|
||||
let mut titles: Vec<(usize, String)> = Vec::new();
|
||||
let parts = if let Some(mb) = max_mb {
|
||||
if !mb.is_finite() || mb <= 0.0 {
|
||||
return Err(ToolError::InvalidArgs("max_mb must be positive".into()));
|
||||
}
|
||||
self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)?
|
||||
} else {
|
||||
let by = match (a.opt_int("every")?, a.opt_ints("before")?) {
|
||||
(Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize),
|
||||
(None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?),
|
||||
_ if bookmarks => {
|
||||
titles = self.session.bookmark_splits(id);
|
||||
if titles.is_empty() {
|
||||
return Err(failed("the document has no top-level bookmarks"));
|
||||
}
|
||||
pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect())
|
||||
}
|
||||
_ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())),
|
||||
};
|
||||
self.session.split(id, &by).map_err(failed)?
|
||||
};
|
||||
let dir = self.resolve(a.str("out_dir")?, true)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let mut files = Vec::new();
|
||||
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 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;
|
||||
let mut n = 1;
|
||||
while !used.insert(unique.to_lowercase()) {
|
||||
n += 1;
|
||||
unique = format!("{base}-{n}.pdf");
|
||||
}
|
||||
let path = child(&dir, &unique);
|
||||
write_atomic(&path, bytes)?;
|
||||
files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last }));
|
||||
}
|
||||
Ok(json!({ "files": files }))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! Geometry follows the automation convention: points from the top-left of the displayed page,
|
||||
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
||||
use pdfcraft_render::{Annotation, PageInfo};
|
||||
use serde_json::{Value, json};
|
||||
@@ -152,8 +154,13 @@ impl Automation {
|
||||
fn comment_target(&self, a: &Args) -> Result<(usize, usize)> {
|
||||
let all = self.comments(a)?;
|
||||
if let Some(id) = a.opt_str("id")? {
|
||||
let c =
|
||||
all.iter().find(|c| c.name.as_deref() == Some(id)).ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?;
|
||||
// `/NM` need not be unique (PyMuPDF names the first comment on every page `fitz-A0`):
|
||||
// the first match could be another page's comment, so a shared id is refused.
|
||||
let mut named = all.iter().filter(|c| c.name.as_deref() == Some(id));
|
||||
let c = named.next().ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?;
|
||||
if named.next().is_some() {
|
||||
return Err(failed(format!("several comments have the id {id:?}: pass page and index instead (see comment_list)")));
|
||||
}
|
||||
return Ok((c.page, c.index));
|
||||
}
|
||||
let (Some(page), Some(index)) = (a.opt_int("page")?, a.opt_int("index")?) else {
|
||||
@@ -193,33 +200,44 @@ impl Automation {
|
||||
}
|
||||
v
|
||||
};
|
||||
// Every reply under the comment its thread starts from, at any depth, in document order:
|
||||
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
|
||||
// drop it. Parents are found by reference, as `/NM` need not be unique. A status or
|
||||
// checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the
|
||||
// Comments panel: one set on a reply is not the comment's.
|
||||
let tops = pdfcraft_render::thread_tops(&all);
|
||||
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
for r in &all {
|
||||
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
|
||||
let key = match r.parent {
|
||||
Some(parent) if r.state.is_some() => parent,
|
||||
_ => top,
|
||||
};
|
||||
threads.entry(key).or_default().push(r);
|
||||
}
|
||||
// An annotation's review status and checkmark: the latest of each set on it.
|
||||
let states = |c: &Annotation, v: &mut Value| {
|
||||
let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()));
|
||||
v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
|
||||
};
|
||||
let roots: Vec<Value> = all
|
||||
.iter()
|
||||
.filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p))
|
||||
.filter(|c| c.is_comment() && only.is_none_or(|p| c.page == p))
|
||||
.map(|c| {
|
||||
let mut v = view(c);
|
||||
// Every reply under this comment, at any depth, in document order. A reply to a
|
||||
// reply keeps its `/IRT` on the direct parent, so following one level would drop it.
|
||||
let descends_from = |r: &Annotation, root: &str| -> bool {
|
||||
let mut parent = r.in_reply_to.as_deref();
|
||||
for _ in 0..64 {
|
||||
let Some(nm) = parent else { return false };
|
||||
if nm == root {
|
||||
return true;
|
||||
}
|
||||
parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref());
|
||||
}
|
||||
false
|
||||
};
|
||||
let thread: Vec<&Annotation> = match c.name.as_deref() {
|
||||
Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone());
|
||||
v["status"] = json!(status);
|
||||
v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
|
||||
states(c, &mut v);
|
||||
v["locked"] = json!(c.locked);
|
||||
v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).collect();
|
||||
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
v["replies"] = thread
|
||||
.iter()
|
||||
.filter(|r| r.state.is_none())
|
||||
.map(|r| {
|
||||
let mut reply = view(r);
|
||||
states(r, &mut reply);
|
||||
reply
|
||||
})
|
||||
.collect();
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -188,8 +188,10 @@ impl Automation {
|
||||
i.flip_v = !i.flip_v;
|
||||
}
|
||||
if let Some(c) = rect_arg(a, "crop")? {
|
||||
if !c.iter().all(|v| (0.0..0.5).contains(v)) {
|
||||
return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)"));
|
||||
if !pdfcraft_engine::AddedImage::crop_valid(c) {
|
||||
return Err(bad(
|
||||
"crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95",
|
||||
));
|
||||
}
|
||||
i.crop = c;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
|
||||
use super::paths::{read_input, write_atomic};
|
||||
use super::*;
|
||||
|
||||
/// One piece of a tool's result.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Content {
|
||||
/// Structured data (MCP: a text block with the JSON plus `structuredContent`).
|
||||
Json(Value),
|
||||
/// A PNG image.
|
||||
Png { data: Vec<u8>, width: u32, height: u32 },
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Run the tool `name` with JSON `args` (an object; `null` means no arguments).
|
||||
pub fn call(&mut self, name: &str, args: &Value) -> Result<Vec<Content>> {
|
||||
let empty = json!({});
|
||||
let args = if args.is_null() { &empty } else { args };
|
||||
if !args.is_object() {
|
||||
return Err(ToolError::InvalidArgs("arguments must be a JSON object".into()));
|
||||
}
|
||||
let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?;
|
||||
tools::check_args(def, args)?;
|
||||
let a = Args(args);
|
||||
let out = match name {
|
||||
"doc_open" => self.doc_open(&a)?,
|
||||
"doc_batch" => self.doc_batch(&a)?,
|
||||
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::<Vec<_>>() }),
|
||||
"doc_info" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
self.session.refresh_fonts(id);
|
||||
info(self.doc(&a)?)
|
||||
}
|
||||
"doc_close" => self.doc_close(&a)?,
|
||||
"doc_save" => self.doc_save(&a)?,
|
||||
"doc_set_info" => {
|
||||
let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() };
|
||||
self.apply(&a, edit)?
|
||||
}
|
||||
"page_render" => return self.page_render(&a).map(|c| vec![c]),
|
||||
"comment_image_preview" => return self.comment_image_preview(&a),
|
||||
"text_extract" => self.text_extract(&a)?,
|
||||
"text_word_count" => self.text_word_count(&a)?,
|
||||
"text_find" => self.text_find(&a)?,
|
||||
"page_rotate" => {
|
||||
let degrees = a.int("degrees")?;
|
||||
if degrees % 90 != 0 {
|
||||
return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into()));
|
||||
}
|
||||
let doc = self.doc(&a)?;
|
||||
let base = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(&a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
let parity = match a.opt_str("subset")?.unwrap_or("all") {
|
||||
"all" => pdfcraft_engine::PageParity::Both,
|
||||
"even" => pdfcraft_engine::PageParity::Even,
|
||||
"odd" => pdfcraft_engine::PageParity::Odd,
|
||||
s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))),
|
||||
};
|
||||
let orientation = match a.opt_str("orientation")?.unwrap_or("all") {
|
||||
"all" => pdfcraft_engine::PageOrientation::Both,
|
||||
"landscape" => pdfcraft_engine::PageOrientation::Landscape,
|
||||
"portrait" => pdfcraft_engine::PageOrientation::Portrait,
|
||||
o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))),
|
||||
};
|
||||
let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation);
|
||||
if pages.is_empty() {
|
||||
return Err(failed("no pages match the filters"));
|
||||
}
|
||||
let n = pages.len();
|
||||
let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?;
|
||||
out["rotated"] = json!(n);
|
||||
out
|
||||
}
|
||||
"page_enhance_camera" => {
|
||||
let page = self.page(&a)?;
|
||||
let given = a.nums::<8>("corners")?;
|
||||
let doc = self.doc(&a)?;
|
||||
let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?);
|
||||
let corners = match given {
|
||||
Some(c) => [0, 1, 2, 3].map(|i| {
|
||||
let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32);
|
||||
[x as f64, y as f64]
|
||||
}),
|
||||
None => self.session.detect_camera_corners(id, page).ok_or_else(|| {
|
||||
failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]")
|
||||
})?,
|
||||
};
|
||||
let level = match a.opt_int("level")? {
|
||||
None => 0,
|
||||
Some(l @ 0..=100) => l as u8,
|
||||
Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))),
|
||||
};
|
||||
let look = match a.opt_str("mode")? {
|
||||
None => pdfcraft_engine::camera_clean::Look::Colour,
|
||||
Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?,
|
||||
};
|
||||
self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?;
|
||||
let r = |v: f32| (v * 100.0).round() / 100.0;
|
||||
let used: Vec<f32> = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect();
|
||||
let mut out = summary(self.doc(&a)?);
|
||||
out["corners"] = json!(used);
|
||||
out["detected"] = json!(given.is_none());
|
||||
out
|
||||
}
|
||||
"page_delete" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
self.apply(&a, Edit::DeletePages { pages })?
|
||||
}
|
||||
"page_move" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
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)?,
|
||||
"doc_combine" => self.doc_combine(&a)?,
|
||||
"doc_create_multiple" => self.doc_create_multiple(&a)?,
|
||||
"doc_split" => self.doc_split(&a)?,
|
||||
"edit_undo" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let label = self.session.undo(id).map_err(failed)?.to_string();
|
||||
json!({ "undone": label, "document": summary(self.doc(&a)?) })
|
||||
}
|
||||
"edit_redo" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let label = self.session.redo(id).map_err(failed)?.to_string();
|
||||
json!({ "redone": label, "document": summary(self.doc(&a)?) })
|
||||
}
|
||||
"command_list" => self.command_list(&a)?,
|
||||
"command_run" => return self.command_run(&a),
|
||||
"command_batch" => self.command_batch(&a)?,
|
||||
"doc_inspect" => self.doc_inspect(&a)?,
|
||||
"render_preview" => return self.render_preview(&a).map(|c| vec![c]),
|
||||
"page_number" => {
|
||||
use pdfcraft_organize::LabelStyle as L;
|
||||
let n = self.doc(&a)?.info.pages.len();
|
||||
let (from, to) = (a.int("from")?, a.int("to")?);
|
||||
if from < 1 || to < from || to as usize > n {
|
||||
return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}")));
|
||||
}
|
||||
let style = match a.opt_str("style")?.unwrap_or("decimal") {
|
||||
"decimal" => L::Decimal,
|
||||
"upper-roman" => L::UpperRoman,
|
||||
"lower-roman" => L::LowerRoman,
|
||||
"upper-alpha" => L::UpperAlpha,
|
||||
"lower-alpha" => L::LowerAlpha,
|
||||
"none" => L::None,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))),
|
||||
};
|
||||
let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string();
|
||||
let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32;
|
||||
let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?;
|
||||
out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::<Vec<_>>());
|
||||
out
|
||||
}
|
||||
"bookmark_list" => self.bookmark_listing(&a)?,
|
||||
"bookmark_add" => {
|
||||
let page = self.page(&a)?;
|
||||
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();
|
||||
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());
|
||||
self.apply(&a, Edit::RenameBookmark { path, title })?
|
||||
}
|
||||
"bookmark_delete" => {
|
||||
let path = a.path("path")?;
|
||||
self.apply(&a, Edit::DeleteBookmark { path })?
|
||||
}
|
||||
"bookmark_move" => {
|
||||
let from = a.path("path")?;
|
||||
let to_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);
|
||||
self.apply(&a, Edit::MoveBookmark { from, to_parent, index })?
|
||||
}
|
||||
"bookmark_set_page" => {
|
||||
let (path, page) = (a.path("path")?, self.page(&a)?);
|
||||
self.apply(&a, Edit::SetBookmarkPage { path, page })?
|
||||
}
|
||||
"bookmark_set_style" => {
|
||||
let path = a.path("path")?;
|
||||
// Left out keeps the colour; [0, 0, 0] is the default black.
|
||||
let color = match a.get("color") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let rgb: Option<Vec<f64>> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect());
|
||||
match rgb.as_deref() {
|
||||
Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]),
|
||||
_ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?);
|
||||
if color.is_none() && bold.is_none() && italic.is_none() {
|
||||
return Err(ToolError::InvalidArgs("give color, bold or italic".into()));
|
||||
}
|
||||
let mut edits = Vec::new();
|
||||
if let Some(color) = color {
|
||||
edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) });
|
||||
}
|
||||
if bold.is_some() || italic.is_some() {
|
||||
edits.push(Edit::SetBookmarkStyle { path, bold, italic });
|
||||
}
|
||||
// A colour and a style given together are one step to undo, applied all or nothing.
|
||||
match edits.len() {
|
||||
1 => self.apply(&a, edits.remove(0))?,
|
||||
_ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?,
|
||||
}
|
||||
}
|
||||
"bookmark_from_structure" => {
|
||||
let mut out = self.apply(&a, Edit::BookmarksFromStructure)?;
|
||||
let listing = self.bookmark_listing(&a)?;
|
||||
out["bookmarks"] = listing["bookmarks"].clone();
|
||||
out["next"] = listing["next"].clone();
|
||||
out
|
||||
}
|
||||
"doc_protect" => self.doc_protect(&a)?,
|
||||
"page_replace" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = Arc::new(read_input(&path)?);
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let src_pages = match a.opt_ints("from_pages")? {
|
||||
Some(p) => one_based(&p)?,
|
||||
None => (0..pages.len()).collect(),
|
||||
};
|
||||
self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })?
|
||||
}
|
||||
"page_duplicate" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
let at = self.opt_position(&a, "at")?;
|
||||
self.apply(&a, Edit::DuplicatePages { pages, at })?
|
||||
}
|
||||
"page_set_box" => self.page_set_box(&a)?,
|
||||
"page_transition_set" => self.page_transition_set(&a)?,
|
||||
"doc_create" => self.doc_create(&a)?,
|
||||
"xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?,
|
||||
"doc_flatten" => {
|
||||
let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true));
|
||||
if !comments && !fields {
|
||||
return Err(ToolError::InvalidArgs("nothing to flatten".into()));
|
||||
}
|
||||
self.apply(&a, Edit::Flatten { comments, fields })?
|
||||
}
|
||||
"doc_reduce" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let before = self.doc(&a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?;
|
||||
// As in the app, a copy that isn't smaller is not written (#490).
|
||||
let written = bytes.len() < before;
|
||||
if written {
|
||||
write_atomic(&path, &bytes)?;
|
||||
}
|
||||
json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged })
|
||||
}
|
||||
"doc_optimize" => self.doc_optimize(&a)?,
|
||||
"doc_initial_view" => self.doc_initial_view(&a)?,
|
||||
"doc_revisions" => self.doc_revisions(&a)?,
|
||||
"text_lines" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let lines: Vec<Value> = doc
|
||||
.text_lines(page)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32));
|
||||
json!({
|
||||
"line": i + 1,
|
||||
"text": l.text,
|
||||
"rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))],
|
||||
"font": l.base_font,
|
||||
"size": (l.size * 100.0).round() / 100.0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "page": page + 1, "count": lines.len(), "lines": lines })
|
||||
}
|
||||
"image_hit" => {
|
||||
let page = self.page(&a)?;
|
||||
let point = a
|
||||
.get("point")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|p| p.len() == 2)
|
||||
.and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
|
||||
.filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?;
|
||||
let doc = self.doc(&a)?;
|
||||
let p = &doc.info.pages[page];
|
||||
let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from);
|
||||
let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect();
|
||||
json!({"page":page+1,"images":images})
|
||||
}
|
||||
"page_images" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let list: Vec<Value> = doc
|
||||
.page_images(page)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, im)| {
|
||||
let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32));
|
||||
let visible = im.visible_rect.map(|rect| {
|
||||
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
|
||||
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
|
||||
});
|
||||
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
|
||||
})
|
||||
.collect();
|
||||
json!({ "page": page + 1, "count": list.len(), "images": list })
|
||||
}
|
||||
"image_edit" | "image_save" => {
|
||||
let page = self.page(&a)?;
|
||||
let n = self.doc(&a)?.page_images(page).len();
|
||||
let k = a.int("image")?;
|
||||
if k < 1 || k as usize > n {
|
||||
return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1)));
|
||||
}
|
||||
let index = k as usize - 1;
|
||||
if name == "image_save" {
|
||||
let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?;
|
||||
let mut path = self.resolve(a.str("path")?, true)?;
|
||||
if path.is_dir() {
|
||||
// Adding the extension to a folder's name would write beside it: "." is the
|
||||
// root, so that would land outside it.
|
||||
return Err(failed(format!("{}: is a folder, not a file", path.display())));
|
||||
}
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(ext);
|
||||
}
|
||||
write_atomic(&path, &bytes)?;
|
||||
json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() })
|
||||
} else {
|
||||
use pdfcraft_engine::ImageEdit;
|
||||
let change = match a.str("action")? {
|
||||
"move" => {
|
||||
let r: Vec<f64> =
|
||||
a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?;
|
||||
// Top-left-origin points → user space.
|
||||
let info = &self.doc(&a)?.info.pages[page];
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::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> =
|
||||
a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let r =
|
||||
<[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?;
|
||||
let info = &self.doc(&a)?.info.pages[page];
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
"delete" => ImageEdit::Delete,
|
||||
"replace" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
ImageEdit::Replace {
|
||||
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
bytes: Arc::new(bytes),
|
||||
}
|
||||
}
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))),
|
||||
};
|
||||
self.apply(&a, Edit::EditPageImage { page, index, change })?
|
||||
}
|
||||
}
|
||||
"text_paragraphs" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let current = doc.text_blocks(page);
|
||||
let id = doc.id;
|
||||
let blocks: Vec<Value> = current
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| {
|
||||
let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32));
|
||||
json!({
|
||||
"paragraph": i + 1,
|
||||
"text": b.text,
|
||||
"editor_text": b.editor_text,
|
||||
"rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"lines": b.lines.iter().map(|l| l + 1).collect::<Vec<_>>(),
|
||||
"rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))],
|
||||
"font": b.base_font,
|
||||
"size": (b.size * 100.0).round() / 100.0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.record_listing(id, page, ¤t);
|
||||
json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks })
|
||||
}
|
||||
"text_font_files" => {
|
||||
let roots = if let Some(paths) = a.get("directories") {
|
||||
let paths = paths
|
||||
.as_array()
|
||||
.filter(|p| p.len() <= 16)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?;
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if let Some(roots) = &self.roots {
|
||||
// The installed fonts are outside the roots: look in the read root instead.
|
||||
let read = roots
|
||||
.read_root()
|
||||
.ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?;
|
||||
vec![self.resolve(&read.to_string_lossy(), false)?]
|
||||
} else if let Some(root) = &self.root {
|
||||
vec![root.clone()]
|
||||
} else {
|
||||
pdfcraft_engine::text_fonts::installed_directories()
|
||||
};
|
||||
json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::<Vec<_>>()})
|
||||
}
|
||||
"text_font_import" => {
|
||||
let page = self.page(&a)?;
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let file = std::fs::File::open(&path).map_err(failed)?;
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?;
|
||||
if bytes.len() > 16 << 20 {
|
||||
return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into()));
|
||||
}
|
||||
let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect();
|
||||
let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?;
|
||||
let added = self
|
||||
.doc(&a)?
|
||||
.text_editor_fonts(page)
|
||||
.into_iter()
|
||||
.find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved"));
|
||||
if let Some(f) = added {
|
||||
out["resource"] = json!(f.resource);
|
||||
out["font"] = json!(f.face);
|
||||
}
|
||||
out
|
||||
}
|
||||
"text_fonts" => {
|
||||
let page = self.page(&a)?;
|
||||
json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::<Vec<_>>()})
|
||||
}
|
||||
"text_edit_layout" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc(&a)?;
|
||||
let k = a.int("paragraph")?;
|
||||
let blocks = doc.text_blocks(page);
|
||||
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")?,
|
||||
width: a.opt_num("width")?,
|
||||
resource: a.opt_str("font_resource")?.map(str::to_owned),
|
||||
ranges: parse_style_ranges(a.get("ranges"))?,
|
||||
..Default::default()
|
||||
};
|
||||
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);
|
||||
let b = info.user_to_view(r[2] as f32, r[3] as f32);
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||||
};
|
||||
let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect();
|
||||
json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect),
|
||||
"draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts })
|
||||
}
|
||||
"text_edit" if a.get("paragraph").is_some() => {
|
||||
let page = self.page(&a)?;
|
||||
let k = a.int("paragraph")?;
|
||||
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"))?,
|
||||
resource: a.opt_str("font_resource")?.map(str::to_owned),
|
||||
bold: a.opt_bool("bold")?,
|
||||
size: a.opt_num("size")?,
|
||||
underline: a.opt_bool("underline")?,
|
||||
line_spacing: a.opt_num("line_spacing")?,
|
||||
char_spacing: a.opt_num("char_spacing")?,
|
||||
scale: a.opt_num("scale")?,
|
||||
width: a.opt_num("width")?,
|
||||
..Default::default()
|
||||
};
|
||||
let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?);
|
||||
if dx.is_some() || dy.is_some() {
|
||||
style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]);
|
||||
}
|
||||
if let Some(f) = a.opt_str("font")? {
|
||||
let family = match f {
|
||||
"helvetica" => pdfcraft_engine::FontFamily::Helvetica,
|
||||
"times" => pdfcraft_engine::FontFamily::Times,
|
||||
"courier" => pdfcraft_engine::FontFamily::Courier,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))),
|
||||
};
|
||||
style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false)));
|
||||
}
|
||||
if let Some(c) = a.opt_str("color")? {
|
||||
style.color = Some(comments::parse_color(c)?);
|
||||
}
|
||||
if let Some(al) = a.opt_str("align")? {
|
||||
style.align = Some(match al {
|
||||
"left" => pdfcraft_engine::TextAlign::Left,
|
||||
"center" => pdfcraft_engine::TextAlign::Center,
|
||||
"right" => pdfcraft_engine::TextAlign::Right,
|
||||
"justify" => pdfcraft_engine::TextAlign::Justify,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
|
||||
});
|
||||
}
|
||||
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
|
||||
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
|
||||
let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy];
|
||||
let wanted: String = text.split_whitespace().collect();
|
||||
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?;
|
||||
let doc = self.doc(&a)?;
|
||||
let after = doc.text_blocks(page);
|
||||
// Without `text` the paragraph keeps the document's own words: not echoed when
|
||||
// the document doesn't allow copying them.
|
||||
let copy = doc.allows_copying();
|
||||
// Only a paragraph that now shows exactly the requested text is the edited one. A
|
||||
// neighbour that overlaps the old box (overlaid text, a text layer) must not take
|
||||
// its number, and neither must a paragraph the edit merged it into: rewriting that
|
||||
// one by this number would drop the neighbour's text.
|
||||
let edited = block_overlapping(&after, moved)
|
||||
.filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::<String>() == wanted));
|
||||
if let Some(b) = edited.and_then(|i| after.get(i)) {
|
||||
out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font });
|
||||
if copy {
|
||||
out["paragraph"]["text"] = json!(b.text);
|
||||
}
|
||||
}
|
||||
let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after);
|
||||
if !stale.is_empty() {
|
||||
out["stale_paragraphs"] = json!(stale);
|
||||
}
|
||||
out
|
||||
}
|
||||
"text_edit" => {
|
||||
let page = self.page(&a)?;
|
||||
let n = self.doc(&a)?.text_lines(page).len();
|
||||
let line = a.int("line")?;
|
||||
if line < 1 || line as usize > n {
|
||||
return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1)));
|
||||
}
|
||||
let text = a.str("text")?.to_owned();
|
||||
let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?;
|
||||
let after = self.doc(&a)?.text_lines(page);
|
||||
if let Some(l) = after.get(line as usize - 1) {
|
||||
out["line"] = json!({ "text": l.text, "font": l.base_font });
|
||||
}
|
||||
out
|
||||
}
|
||||
"doc_audit_space" => {
|
||||
let rows: Vec<Value> = self
|
||||
.doc(&a)?
|
||||
.audit_space()
|
||||
.iter()
|
||||
.map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 }))
|
||||
.collect();
|
||||
json!({ "categories": rows })
|
||||
}
|
||||
"doc_open_revision" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0);
|
||||
let new = self.session.open_revision(id, n).map_err(failed)?;
|
||||
summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?)
|
||||
}
|
||||
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
|
||||
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
|
||||
"doc_unprotect" => {
|
||||
let mut out = self.apply(&a, Edit::RemoveProtection)?;
|
||||
out["security"] = security(self.doc(&a)?);
|
||||
out
|
||||
}
|
||||
"form_fields" => self.form_fields(&a)?,
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
|
||||
}
|
||||
"form_reset" => self.form_reset(&a)?,
|
||||
"form_add_field" => self.form_add_field(&a)?,
|
||||
"form_add_field_table" => self.form_add_field_table(&a)?,
|
||||
"form_set_props" => self.form_set_props(&a)?,
|
||||
"form_delete_field" => self.form_delete_field(&a)?,
|
||||
"form_tab_order" => self.form_tab_order(&a)?,
|
||||
"form_calculation_order" => self.form_calculation_order(&a)?,
|
||||
"doc_export_data" => self.doc_export_data(&a)?,
|
||||
"doc_import_data" => self.doc_import_data(&a)?,
|
||||
"redact_mark" => self.redact_mark(&a)?,
|
||||
"redact_apply" => self.redact_apply(&a)?,
|
||||
"redact_clear" => self.redact_clear(&a)?,
|
||||
"doc_hidden_info" => self.doc_hidden_info(&a)?,
|
||||
"printers" => self.printers()?,
|
||||
"printer_options" => self.printer_options(&a)?,
|
||||
"link_list" => self.link_list(&a)?,
|
||||
"link_open" => self.link_open(&a)?,
|
||||
"link_add" => self.link_add(&a)?,
|
||||
"link_edit" => self.link_edit(&a)?,
|
||||
"link_delete" => self.link_delete(&a)?,
|
||||
"links_from_urls" => self.links_from_urls(&a)?,
|
||||
"links_remove" => self.links_remove(&a)?,
|
||||
"object_list" => self.object_list(&a)?,
|
||||
"object_move" => self.object_move(&a)?,
|
||||
"content_list" => self.content_list(&a)?,
|
||||
"page_add_text" => self.page_add_text(&a)?,
|
||||
"page_add_image" => self.page_add_image(&a)?,
|
||||
"content_update" => self.content_update(&a)?,
|
||||
"content_delete" => self.content_delete(&a)?,
|
||||
"content_copy" => self.content_copy(&a)?,
|
||||
"doc_print" => self.doc_print(&a)?,
|
||||
"attachment_add" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = match a.opt_str("name")? {
|
||||
Some(n) => n.to_string(),
|
||||
None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
};
|
||||
let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string);
|
||||
let description = a.opt_str("description")?.map(str::to_string);
|
||||
// Only the document-level keys: an annotation attachment of the same name is not the new one.
|
||||
let before = document_attachment_keys(self.doc(&a)?);
|
||||
let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?;
|
||||
let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default();
|
||||
json!({ "name": key, "document": out })
|
||||
}
|
||||
"attachment_delete" => {
|
||||
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
|
||||
self.apply(&a, edit)?
|
||||
}
|
||||
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
|
||||
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
|
||||
"fill_sign_add" => self.fill_sign_add(&a)?,
|
||||
"fill_sign_date_format" => self.fill_sign_date_format(&a)?,
|
||||
"measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?,
|
||||
"measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?,
|
||||
"measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?,
|
||||
"measure_info" => self.measurement_info(&a)?,
|
||||
"measure_list" => self.measurement_list(&a)?,
|
||||
"measure_scale" => self.measurement_scale(&a)?,
|
||||
"measure_snap" => self.measurement_snap(&a)?,
|
||||
"measure_export" => self.measurement_export(&a)?,
|
||||
"comment_list" => self.comment_list(&a)?,
|
||||
"comment_add" => self.comment_add(&a)?,
|
||||
"stamp_custom" => self.stamp_custom(&a)?,
|
||||
"comment_reply" => self.comment_reply(&a)?,
|
||||
"comment_set_status" => self.comment_set_status(&a)?,
|
||||
"sign_list" => self.sign_list(&a)?,
|
||||
"accessibility_check" => self.a11y_check(&a)?,
|
||||
"accessibility_find_untagged" => self.a11y_find_untagged(&a)?,
|
||||
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
|
||||
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
|
||||
"accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?,
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
"js_set_document_script" => self.js_set_document_script(&a)?,
|
||||
"js_enabled" => self.js_enabled(&a)?,
|
||||
"form_set_script" => self.form_set_script(&a)?,
|
||||
"form_merge_data" => self.form_merge_data(&a)?,
|
||||
"form_actions" => self.form_actions(&a)?,
|
||||
"form_detect_fields" => self.form_detect_fields(&a)?,
|
||||
"doc_compare" => self.doc_compare(&a)?,
|
||||
"action_list" => self.action_list()?,
|
||||
"pdfa_verify" => self.pdfa_verify(&a)?,
|
||||
"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)?,
|
||||
"form_set_actions" => self.form_set_actions(&a)?,
|
||||
"ocr_status" => self.ocr_status()?,
|
||||
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
|
||||
"accessibility_report" => self.a11y_report(&a)?,
|
||||
"accessibility_review_record" => self.a11y_review_record(&a)?,
|
||||
"accessibility_review_save" => self.a11y_review_save(&a)?,
|
||||
"accessibility_review_load" => self.a11y_review_load(&a)?,
|
||||
"accessibility_review_html" => self.a11y_review_html(&a)?,
|
||||
"accessibility_fix" => self.a11y_fix(&a)?,
|
||||
"accessibility_figures" => self.accessibility_figures(&a)?,
|
||||
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
|
||||
"accessibility_tags" => self.accessibility_tags(&a)?,
|
||||
"accessibility_tag_content" => self.accessibility_tag_content(&a)?,
|
||||
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
|
||||
"accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?,
|
||||
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
|
||||
"sign_id_create" => self.sign_id_create(&a)?,
|
||||
"sign_document" => self.sign_document(&a)?,
|
||||
"sign_keychain_ids" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
let ids: Vec<Value> = pdfcraft_engine::sign::keychain::identities(None)
|
||||
.map_err(failed)?
|
||||
.iter()
|
||||
.map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
|
||||
.collect();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let ids: Vec<Value> = Vec::new();
|
||||
json!({ "count": ids.len(), "ids": ids })
|
||||
}
|
||||
"sign_windows_ids" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let (ids, unusable): (Vec<Value>, Vec<Value>) = {
|
||||
let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?;
|
||||
let ids = listing
|
||||
.ids
|
||||
.iter()
|
||||
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
|
||||
.collect();
|
||||
let unusable = listing
|
||||
.unusable
|
||||
.iter()
|
||||
.map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key }))
|
||||
.collect();
|
||||
(ids, unusable)
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let (ids, unusable): (Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new());
|
||||
json!({ "count": ids.len(), "ids": ids, "unusable": unusable })
|
||||
}
|
||||
"sign_trust" => self.sign_trust(&a)?,
|
||||
"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)?,
|
||||
"comment_delete" => self.comment_delete(&a)?,
|
||||
other => return Err(ToolError::UnknownTool(other.into())),
|
||||
};
|
||||
Ok(vec![Content::Json(out)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, nest_rows, security, summary};
|
||||
use super::paths::{child, read_input, write_atomic};
|
||||
use super::*;
|
||||
|
||||
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
|
||||
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
|
||||
|
||||
/// Bookmarks in one page of the bookmark list, by default and at most.
|
||||
const BOOKMARK_PAGE: usize = 100;
|
||||
|
||||
const MAX_BOOKMARK_PAGE: usize = 1000;
|
||||
|
||||
impl Automation {
|
||||
// ---- documents ---------------------------------------------------------------------------
|
||||
|
||||
/// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`).
|
||||
pub(crate) fn bookmark_listing(&self, a: &Args) -> Result<Value> {
|
||||
let id = a.int("doc")?;
|
||||
let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?);
|
||||
let offset = a.opt_int("offset")?.unwrap_or(0);
|
||||
let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64);
|
||||
let limit = usize::try_from(limit)
|
||||
.ok()
|
||||
.filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l))
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?;
|
||||
let page = self
|
||||
.session
|
||||
.bookmark_page(id, offset, limit)
|
||||
.ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?;
|
||||
let pages = &self.doc(a)?.info.pages;
|
||||
Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc(&self, a: &Args) -> Result<&Document> {
|
||||
let id = a.int("doc")?;
|
||||
let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?;
|
||||
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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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).
|
||||
pub(crate) fn doc_to_copy(&self, a: &Args) -> Result<&Document> {
|
||||
let doc = self.doc(a)?;
|
||||
doc.check_copying().map_err(failed)?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_open(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
|
||||
Ok(summary(doc))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_close(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false);
|
||||
if doc.dirty && !discard_changes {
|
||||
return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true"));
|
||||
}
|
||||
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 }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_save(&mut self, a: &Args) -> Result<Value> {
|
||||
self.doc_save_with(a, write_atomic)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let target = match a.opt_str("path")? {
|
||||
Some(p) => self.resolve(p, true)?,
|
||||
None => {
|
||||
// Saving in place writes where the file was opened from, which the read root
|
||||
// allows and the write root may not.
|
||||
let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?;
|
||||
let own_path = PathBuf::from(&own);
|
||||
self.check_roots(&own, &own_path, file_access::Access::Write)?;
|
||||
own_path
|
||||
}
|
||||
};
|
||||
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
|
||||
// Saving to a new file is a full rewrite unless asked otherwise, like Save As.
|
||||
let full = a.opt_bool("full")?.unwrap_or(!same_file);
|
||||
let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false);
|
||||
if flatten_fill_sign {
|
||||
self.apply(a, Edit::FlattenFillSign)?;
|
||||
}
|
||||
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?;
|
||||
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
|
||||
// redaction), is rewritten in full even when an incremental save was asked for.
|
||||
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
|
||||
self.publish_save(id, &target, bytes, incremental, write)
|
||||
}
|
||||
|
||||
/// Validate the prepared bytes before publishing them or marking the document saved.
|
||||
pub(crate) fn publish_save(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
target: &Path,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
incremental: bool,
|
||||
write: impl FnOnce(&Path, &[u8]) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
self.session.check_save(id, &bytes).map_err(failed)?;
|
||||
write(target, &bytes)?;
|
||||
let path = target.to_string_lossy().into_owned();
|
||||
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_revisions(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let ends = doc.revision_ends();
|
||||
let sigs = doc.signatures.clone();
|
||||
let list: Vec<Value> = ends
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, end)| {
|
||||
let start = if i == 0 { 0 } else { ends[i - 1] };
|
||||
// A signature signs the revision its byte range ends in.
|
||||
let signed: Vec<&str> =
|
||||
sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect();
|
||||
json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed })
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_initial_view(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
|
||||
let bad = |m: String| ToolError::InvalidArgs(m);
|
||||
let mut v = self.doc(a)?.initial_view();
|
||||
let before = v.clone();
|
||||
if let Some(n) = a.opt_str("navigation")? {
|
||||
v.navigation = match n {
|
||||
"page" => N::PageOnly,
|
||||
"bookmarks" => N::Bookmarks,
|
||||
"pages" => N::Pages,
|
||||
"attachments" => N::Attachments,
|
||||
"layers" => N::Layers,
|
||||
o => return Err(bad(format!("unknown navigation {o:?}"))),
|
||||
};
|
||||
}
|
||||
if let Some(l) = a.opt_str("layout")? {
|
||||
v.layout = match l {
|
||||
"default" => L::Default,
|
||||
"single" => L::SinglePage,
|
||||
"continuous" => L::SinglePageContinuous,
|
||||
"two_up" => L::TwoUp,
|
||||
"two_up_continuous" => L::TwoUpContinuous,
|
||||
"two_up_cover" => L::TwoUpCoverPage,
|
||||
"two_up_continuous_cover" => L::TwoUpContinuousCoverPage,
|
||||
o => return Err(bad(format!("unknown layout {o:?}"))),
|
||||
};
|
||||
}
|
||||
match a.get("magnification") {
|
||||
None => {}
|
||||
Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)),
|
||||
Some(Value::String(m)) => {
|
||||
v.magnification = match m.as_str() {
|
||||
"default" => M::Default,
|
||||
"actual" => M::ActualSize,
|
||||
"fit_page" => M::FitPage,
|
||||
"fit_width" => M::FitWidth,
|
||||
"fit_height" => M::FitHeight,
|
||||
"fit_visible" => M::FitVisible,
|
||||
o => return Err(bad(format!("unknown magnification {o:?}"))),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("magnification is a name or a percentage".into())),
|
||||
}
|
||||
if let Some(p) = a.opt_int("page")? {
|
||||
v.page = (p.max(1) - 1) as usize;
|
||||
}
|
||||
for (k, f) in [
|
||||
("fit_window", &mut v.fit_window),
|
||||
("center_window", &mut v.center_window),
|
||||
("full_screen", &mut v.full_screen),
|
||||
("display_title", &mut v.display_title),
|
||||
("hide_menubar", &mut v.hide_menubar),
|
||||
("hide_toolbar", &mut v.hide_toolbar),
|
||||
("hide_window_ui", &mut v.hide_window_ui),
|
||||
] {
|
||||
if let Some(b) = a.opt_bool(k)? {
|
||||
*f = b;
|
||||
}
|
||||
}
|
||||
if let Some(l) = a.opt_str("language")? {
|
||||
v.language = (!l.trim().is_empty()).then(|| l.trim().to_string());
|
||||
}
|
||||
if let Some(b) = a.opt_str("binding")? {
|
||||
v.right_to_left = match b {
|
||||
"left" => false,
|
||||
"right" => true,
|
||||
o => return Err(bad(format!("binding is left or right, not {o:?}"))),
|
||||
};
|
||||
}
|
||||
let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) };
|
||||
out["initial_view"] = json!({
|
||||
"navigation": format!("{:?}", v.navigation),
|
||||
"layout": format!("{:?}", v.layout),
|
||||
"magnification": format!("{:?}", v.magnification),
|
||||
"page": v.page + 1,
|
||||
"fit_window": v.fit_window,
|
||||
"center_window": v.center_window,
|
||||
"full_screen": v.full_screen,
|
||||
"display_title": v.display_title,
|
||||
"hide_menubar": v.hide_menubar,
|
||||
"hide_toolbar": v.hide_toolbar,
|
||||
"hide_window_ui": v.hide_window_ui,
|
||||
"language": v.language,
|
||||
"binding": if v.right_to_left { "right" } else { "left" },
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset};
|
||||
let id = self.doc(a)?.id;
|
||||
let before = self.doc(a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let mut settings = match a.opt_str("preset")? {
|
||||
None => Preset::Standard.settings(),
|
||||
Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(),
|
||||
};
|
||||
if let Some(c) = a.opt_str("compatibility")? {
|
||||
settings.compatibility = Compatibility::from_name(c)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?;
|
||||
}
|
||||
let images = |key: &str, base: ImageSettings| -> Result<ImageSettings> {
|
||||
let Some(v) = a.get(key) else { return Ok(base) };
|
||||
let mut s = base;
|
||||
if let Some(d) = v.get("downsample").and_then(Value::as_bool) {
|
||||
s.downsample = d;
|
||||
}
|
||||
if let Some(p) = v.get("ppi").and_then(Value::as_f64) {
|
||||
s.target_ppi = p.clamp(9.0, 2400.0);
|
||||
}
|
||||
if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) {
|
||||
s.above_ppi = p.clamp(9.0, 2400.0);
|
||||
}
|
||||
s.above_ppi = s.above_ppi.max(s.target_ppi);
|
||||
if let Some(c) = v.get("compression").and_then(Value::as_str) {
|
||||
let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8;
|
||||
s.compression = match c {
|
||||
"jpeg" => Compression::Jpeg(q),
|
||||
"zip" | "flate" => Compression::Flate,
|
||||
"retain" => Compression::Retain,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))),
|
||||
};
|
||||
}
|
||||
Ok(s)
|
||||
};
|
||||
settings.color = images("color", settings.color)?;
|
||||
settings.gray = images("gray", settings.gray)?;
|
||||
for (key, flag) in [
|
||||
("merge_fragments", &mut settings.merge_fragments),
|
||||
("discard_thumbnails", &mut settings.discard_thumbnails),
|
||||
("discard_alternate_images", &mut settings.discard_alternate_images),
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
("discard_print_settings", &mut settings.discard_print_settings),
|
||||
("flate_unencoded", &mut settings.flate_unencoded),
|
||||
("object_streams", &mut settings.object_streams),
|
||||
("lzw_to_flate", &mut settings.lzw_to_flate),
|
||||
("remove_invalid_links", &mut settings.remove_invalid_links),
|
||||
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
|
||||
("discard_external_refs", &mut settings.discard_external_refs),
|
||||
] {
|
||||
if let Some(b) = a.opt_bool(key)? {
|
||||
*flag = b;
|
||||
}
|
||||
}
|
||||
let discard: Vec<pdfcraft_engine::Hidden> = match a.get("discard") {
|
||||
None => Vec::new(),
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))?
|
||||
.iter()
|
||||
.map(|x| {
|
||||
x.as_str()
|
||||
.and_then(pdfcraft_engine::Hidden::from_id)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}")))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
};
|
||||
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
fn valid_version(v: &[u8]) -> Option<&str> {
|
||||
if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None }
|
||||
}
|
||||
let catalog_version = (settings.compatibility == Compatibility::Retain)
|
||||
.then(|| {
|
||||
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
|
||||
let root = doc.root()?;
|
||||
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
|
||||
})
|
||||
.flatten();
|
||||
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
|
||||
// Viewers read the later of the two (object streams raise the header past a lower catalog
|
||||
// entry). Both are "d.d", so text order is version order.
|
||||
let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max();
|
||||
let o = &r.optimize;
|
||||
Ok(json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"pdf_version": pdf_version,
|
||||
"object_streams": settings.effective_object_streams(),
|
||||
"bytes_before": before,
|
||||
"bytes_after": bytes.len(),
|
||||
"merged_images": o.merged_images,
|
||||
"fragments_merged": o.fragments_merged,
|
||||
"images": o.images,
|
||||
"images_resampled": o.images_resampled,
|
||||
"images_recompressed": o.images_recompressed,
|
||||
"image_bytes_before": o.image_bytes_before,
|
||||
"image_bytes_after": o.image_bytes_after,
|
||||
"thumbnails": o.thumbnails,
|
||||
"alternate_images": o.alternate_images,
|
||||
"tags_removed": o.tags_removed,
|
||||
"streams_compressed": o.streams_compressed,
|
||||
"lzw_streams": o.lzw_streams,
|
||||
"invalid_links": o.invalid_links,
|
||||
"invalid_bookmarks": o.invalid_bookmarks,
|
||||
"unreferenced_dests": o.unreferenced_dests,
|
||||
"external_links": o.external_links,
|
||||
"external_bookmarks": o.external_bookmarks,
|
||||
"unused_xobjects": o.unused_xobjects,
|
||||
"merged_objects": r.merged,
|
||||
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based).
|
||||
fn mark_file(&self, a: &Args) -> Result<Option<pdfcraft_engine::MarkFile>> {
|
||||
let Some(p) = a.opt_str("file")? else { return Ok(None) };
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1;
|
||||
Ok(Some(pdfcraft_engine::MarkFile {
|
||||
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
bytes: Arc::new(bytes),
|
||||
page,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
let doc = self.doc(a)?;
|
||||
let mut out = summary(doc);
|
||||
// Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent.
|
||||
if !doc.edit_notices.is_empty() {
|
||||
out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark};
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let color =
|
||||
|key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) };
|
||||
let replace = a.opt_bool("replace")?.unwrap_or(false);
|
||||
let edit = match tool {
|
||||
"doc_header_footer" => {
|
||||
let mut hf = HeaderFooter::default();
|
||||
for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() {
|
||||
hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string();
|
||||
}
|
||||
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();
|
||||
hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?;
|
||||
}
|
||||
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" => {
|
||||
let d = Watermark::default();
|
||||
let file = self.mark_file(a)?;
|
||||
let text = match &file {
|
||||
Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(),
|
||||
None => a.str("text")?.to_string(),
|
||||
};
|
||||
let wm = Watermark {
|
||||
text,
|
||||
source: None,
|
||||
scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0),
|
||||
font_size: a.opt_num("font_size")?.unwrap_or(0.0),
|
||||
color: color("color", d.color)?,
|
||||
opacity: a.opt_num("opacity")?.unwrap_or(d.opacity),
|
||||
rotation: a.opt_num("rotation")?.unwrap_or(d.rotation),
|
||||
behind: a.opt_bool("behind")?.unwrap_or(false),
|
||||
offset: [
|
||||
a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET),
|
||||
a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET),
|
||||
],
|
||||
};
|
||||
Edit::AddWatermark { pages, settings: wm, replace, file }
|
||||
}
|
||||
"doc_background" => Edit::AddBackground {
|
||||
pages,
|
||||
settings: Background {
|
||||
color: color("color", [1.0; 3])?,
|
||||
opacity: a.opt_num("opacity")?.unwrap_or(1.0),
|
||||
scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0),
|
||||
..Background::default()
|
||||
},
|
||||
replace,
|
||||
file: self.mark_file(a)?,
|
||||
},
|
||||
_ => {
|
||||
let kind = match a.str("kind")? {
|
||||
"header_footer" => MarkKind::HeaderFooter,
|
||||
"watermark" => MarkKind::Watermark,
|
||||
"background" => MarkKind::Background,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))),
|
||||
};
|
||||
Edit::RemoveMarks { kind }
|
||||
}
|
||||
};
|
||||
self.apply(a, edit)
|
||||
}
|
||||
|
||||
pub(crate) fn export(&mut self, tool: &str, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
let mut ex = pdfcraft_engine::export::Exporter::new(doc);
|
||||
if tool == "doc_export_text" {
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let text = ex.text_of(&pages).map_err(failed)?;
|
||||
write_atomic(&path, text.as_bytes())?;
|
||||
return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() }));
|
||||
}
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?;
|
||||
if tool == "doc_export_all_images" {
|
||||
let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX);
|
||||
let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?;
|
||||
let mut files = Vec::new();
|
||||
for (k, img) in out.images.iter().enumerate() {
|
||||
let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len()));
|
||||
write_atomic(&path, &img.data)?;
|
||||
files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height }));
|
||||
}
|
||||
let skipped: Vec<Value> = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect();
|
||||
return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped }));
|
||||
}
|
||||
let dpi = a.opt_num("dpi")?.unwrap_or(150.0);
|
||||
let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8;
|
||||
let format = match a.opt_str("format")?.unwrap_or("png") {
|
||||
"png" => pdfcraft_engine::export::ImageFormat::Png,
|
||||
"jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality },
|
||||
"tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff,
|
||||
f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))),
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
let mut lowered = Vec::new();
|
||||
// A write error stops the export as it is, not as a page error.
|
||||
let mut write_error = None;
|
||||
let exported = ex.images(&pages, dpi, format, |p, img| {
|
||||
let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension()));
|
||||
write_atomic(&path, &img).map_err(|e| {
|
||||
let message = e.to_string();
|
||||
write_error = Some(e);
|
||||
message
|
||||
})?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
});
|
||||
if let Some(e) = write_error {
|
||||
return Err(e);
|
||||
}
|
||||
exported.map_err(failed)?;
|
||||
for &p in &pages {
|
||||
// A page too large for the renderer at `dpi` is drawn at the most it allows.
|
||||
let used = ex.dpi_used(p, dpi);
|
||||
if used < dpi.clamp(18.0, 1200.0) - 0.5 {
|
||||
lowered.push(json!({ "page": p + 1, "dpi": used.floor() }));
|
||||
}
|
||||
}
|
||||
if lowered.is_empty() {
|
||||
Ok(json!({ "count": files.len(), "files": files }))
|
||||
} else {
|
||||
Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered }))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn doc_protect(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{Algorithm, Changes, Printing, Protection};
|
||||
let d = Protection::default();
|
||||
// Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is
|
||||
// enforced against the owner password; without one everything stays allowed). Refuse a
|
||||
// restriction that could not take effect instead of writing an unrestricted file (#134).
|
||||
let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some());
|
||||
if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted"
|
||||
)));
|
||||
}
|
||||
let p = Protection {
|
||||
open_password: a.opt_str("open_password")?.map(str::to_owned),
|
||||
permissions_password: a.opt_str("permissions_password")?.map(str::to_owned),
|
||||
printing: match a.opt_str("printing")? {
|
||||
None => d.printing,
|
||||
Some("none") => Printing::None,
|
||||
Some("low") => Printing::Low,
|
||||
Some("high") => Printing::High,
|
||||
Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))),
|
||||
},
|
||||
changes: match a.opt_str("changes")? {
|
||||
None => d.changes,
|
||||
Some("none") => Changes::None,
|
||||
Some("pages") => Changes::Pages,
|
||||
Some("fill-sign") => Changes::FillSign,
|
||||
Some("comment-fill-sign") => Changes::CommentFillSign,
|
||||
Some("any-except-extract") => Changes::AnyExceptExtract,
|
||||
Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))),
|
||||
},
|
||||
copy: a.opt_bool("copy")?.unwrap_or(d.copy),
|
||||
accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility),
|
||||
algorithm: match a.opt_str("compatibility")? {
|
||||
None | Some("aes-256") => Algorithm::Aes256,
|
||||
Some("aes-128") => Algorithm::Aes128,
|
||||
Some("rc4-128") => Algorithm::Rc4_128,
|
||||
Some("rc4-40") => Algorithm::Rc4_40,
|
||||
Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))),
|
||||
},
|
||||
encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata),
|
||||
};
|
||||
let mut out = self.apply(a, Edit::Protect(p))?;
|
||||
out["security"] = security(self.doc(a)?);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// Why a tool call failed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ToolError {
|
||||
/// No tool has this name.
|
||||
UnknownTool(String),
|
||||
/// The arguments don't match the tool's schema.
|
||||
InvalidArgs(String),
|
||||
/// The tool ran and failed (the message is for the agent to read).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"),
|
||||
ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ToolError {}
|
||||
|
||||
pub(crate) type Result<T> = std::result::Result<T, ToolError>;
|
||||
|
||||
pub(crate) fn failed(e: impl std::fmt::Display) -> ToolError {
|
||||
ToolError::Failed(e.to_string())
|
||||
}
|
||||
|
||||
impl From<pdfcraft_engine::EditError> for ToolError {
|
||||
fn from(e: pdfcraft_engine::EditError) -> Self {
|
||||
failed(e)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
|
||||
//! than the root's is refused by name, before anything is resolved: resolving it would contact
|
||||
//! the host.
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
@@ -97,7 +100,10 @@ impl AutomationRoots {
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(shown, target)?, folder) {
|
||||
let absolute = absolute(shown, target)?;
|
||||
// Another network share or a device is refused by name, as `--root` refuses it: even
|
||||
// resolving the path would contact the host (#4003).
|
||||
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = root_shown.display();
|
||||
@@ -123,9 +129,8 @@ fn plain(path: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
/// `path` made absolute against the working directory, by name: nothing on disk is read.
|
||||
fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
@@ -133,10 +138,15 @@ fn resolve(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
|
||||
}
|
||||
|
||||
/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing
|
||||
/// folder's links followed and the rest appended.
|
||||
fn resolve(shown: &str, absolute: &Path) -> Result<PathBuf, String> {
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
let mut at = absolute;
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
@@ -189,6 +199,24 @@ fn same_component(a: Component, b: Component) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names another network share or device namespace than `root` (Windows
|
||||
/// `\\host\share`, `\\?\UNC\…`, `\\.\…`, `\\?\…`). Those are refused by name: even checking
|
||||
/// that one exists would contact the host. Drive letters are left to the normal check.
|
||||
pub(crate) fn foreign_share(path: &Path, root: &Path) -> bool {
|
||||
use std::path::Prefix;
|
||||
fn share(p: &Path) -> Option<String> {
|
||||
let Some(Component::Prefix(prefix)) = p.components().next() else { return None };
|
||||
let (kind, a, b) = match prefix.kind() {
|
||||
Prefix::Disk(_) | Prefix::VerbatimDisk(_) => return None,
|
||||
Prefix::UNC(host, share) | Prefix::VerbatimUNC(host, share) => ("unc", host, share),
|
||||
Prefix::DeviceNS(name) => ("device", name, std::ffi::OsStr::new("")),
|
||||
Prefix::Verbatim(name) => ("verbatim", name, std::ffi::OsStr::new("")),
|
||||
};
|
||||
Some(format!("{kind}\\{}\\{}", a.to_string_lossy(), b.to_string_lossy()).to_lowercase())
|
||||
}
|
||||
share(path).is_some_and(|s| share(root).as_ref() != Some(&s))
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
@@ -233,6 +261,27 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// #4003: another share or a device namespace is refused by name, with the same words as any
|
||||
/// other path outside the root, before anything is resolved (which would contact the host).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn other_shares_and_devices_are_refused_before_they_are_resolved() {
|
||||
let (base, roots) = folders("shares");
|
||||
for p in [
|
||||
r"\\.\pipe\pdfcraft-4003.pdf",
|
||||
r"\\?\GLOBALROOT\Device\Null",
|
||||
r"\\pdfcraft-no-such-host.invalid\share\a.pdf",
|
||||
"//pdfcraft-no-such-host.invalid/share/a.pdf",
|
||||
r"\\?\UNC\pdfcraft-no-such-host.invalid\share\a.pdf",
|
||||
] {
|
||||
let e = roots.check(p, Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{p}: {e}");
|
||||
let e = roots.check(p, Access::Write).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the write root"), "{p}: {e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
@@ -347,22 +396,41 @@ mod tests {
|
||||
// discovered file again before `process` reads it or `publish` writes it.
|
||||
const RESOLVED_BY_CALLER: &[&str] =
|
||||
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
||||
let mut files = vec![];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
if path.file_name().is_some_and(|n| !n.to_string_lossy().starts_with("tests")) {
|
||||
dirs.push(path);
|
||||
}
|
||||
} else {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut offenders = vec![];
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
for path in files {
|
||||
if path.extension().is_none_or(|e| e != "rs")
|
||||
|| path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// Test modules (which build their own fixtures) come last in each file.
|
||||
let code = text.split("\n#[cfg(test)]").next().unwrap_or("");
|
||||
// Inline test modules (which build their own fixtures) come last in each file. A
|
||||
// `#[cfg(test)]` on a `use` line or a `mod tests;` declaration doesn't start one.
|
||||
let code = text
|
||||
.match_indices("\n#[cfg(test)]\nmod ")
|
||||
.find(|(at, m)| text[at + m.len()..].lines().next().is_some_and(|l| l.trim_end().ends_with('{')))
|
||||
.map_or(text.as_str(), |(at, _)| &text[..at]);
|
||||
let lines: Vec<&str> = code.lines().collect();
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// A `use` line only names a helper; the call is checked where it is made.
|
||||
if trimmed.starts_with("//")
|
||||
|| trimmed.starts_with("use ")
|
||||
|| (trimmed.starts_with("pub") && trimmed.contains(" use "))
|
||||
|| !(trimmed.contains("std::fs")
|
||||
|| trimmed.contains("File::open")
|
||||
|| trimmed.contains("File::create")
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
use super::error::{Result, ToolError};
|
||||
use super::*;
|
||||
|
||||
/// 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;
|
||||
|
||||
// ---- JSON helpers ----------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
|
||||
let Some(value) = value else { return Ok(Vec::new()) };
|
||||
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
|
||||
values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let a = Args(v);
|
||||
let bounds = a
|
||||
.get("range")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|v| v.len() == 2)
|
||||
.ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?;
|
||||
let start = bounds
|
||||
.first()
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?;
|
||||
let end = bounds
|
||||
.get(1)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?;
|
||||
let family = a
|
||||
.opt_str("font")?
|
||||
.map(|f| match f {
|
||||
"helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica),
|
||||
"times" => Ok(pdfcraft_engine::FontFamily::Times),
|
||||
"courier" => Ok(pdfcraft_engine::FontFamily::Courier),
|
||||
_ => Err(Args::wrong("font", "helvetica, times or courier")),
|
||||
})
|
||||
.transpose()?
|
||||
.map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) })
|
||||
.transpose()?;
|
||||
Ok(pdfcraft_engine::RangeStyle {
|
||||
range: start..end,
|
||||
family,
|
||||
resource: a.opt_str("font_resource")?.map(str::to_owned),
|
||||
size: a.opt_num("size")?,
|
||||
color: a.opt_str("color")?.map(comments::parse_color).transpose()?,
|
||||
underline: a.opt_bool("underline")?,
|
||||
char_spacing: a.opt_num("char_spacing")?,
|
||||
scale: a.opt_num("scale")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Typed access to validated arguments.
|
||||
pub(crate) struct Args<'a>(pub(crate) &'a Value);
|
||||
|
||||
impl Args<'_> {
|
||||
pub(crate) fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.0.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
pub(crate) fn missing(key: &str) -> ToolError {
|
||||
ToolError::InvalidArgs(format!("missing argument {key}"))
|
||||
}
|
||||
pub(crate) fn wrong(key: &str, what: &str) -> ToolError {
|
||||
ToolError::InvalidArgs(format!("{key} must be {what}"))
|
||||
}
|
||||
pub(crate) fn str(&self, key: &str) -> Result<&str> {
|
||||
self.opt_str(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
pub(crate) fn opt_str(&self, key: &str) -> Result<Option<&str>> {
|
||||
self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose()
|
||||
}
|
||||
pub(crate) fn int(&self, key: &str) -> Result<i64> {
|
||||
self.opt_int(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
pub(crate) fn opt_int(&self, key: &str) -> Result<Option<i64>> {
|
||||
self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose()
|
||||
}
|
||||
pub(crate) fn opt_num(&self, key: &str) -> Result<Option<f64>> {
|
||||
self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose()
|
||||
}
|
||||
pub(crate) fn opt_bool(&self, key: &str) -> Result<Option<bool>> {
|
||||
self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose()
|
||||
}
|
||||
pub(crate) fn ints(&self, key: &str) -> Result<Vec<i64>> {
|
||||
self.opt_ints(key)?.ok_or_else(|| Self::missing(key))
|
||||
}
|
||||
pub(crate) fn opt_ints(&self, key: &str) -> Result<Option<Vec<i64>>> {
|
||||
let Some(v) = self.get(key) else { return Ok(None) };
|
||||
let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?;
|
||||
arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
/// A 1-based bookmark path → 0-based indices.
|
||||
pub(crate) fn path(&self, key: &str) -> Result<Vec<usize>> {
|
||||
let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?;
|
||||
if p.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!("{key} must not be empty")));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
pub(crate) fn opt_path(&self, key: &str) -> Result<Option<Vec<usize>>> {
|
||||
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.
|
||||
pub(crate) 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"))),
|
||||
}
|
||||
}
|
||||
pub(crate) 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"))?;
|
||||
arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments.
|
||||
pub(crate) fn document_attachment_keys(doc: &Document) -> Vec<String> {
|
||||
doc.info
|
||||
.attachments
|
||||
.iter()
|
||||
.filter_map(|x| match &x.source {
|
||||
pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()),
|
||||
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the
|
||||
/// next level down in `children`, and paths and pages are 1-based.
|
||||
pub(crate) fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec<Value> {
|
||||
// The open rows, outermost first, with their depth and the children gathered so far.
|
||||
let mut open: Vec<(usize, Value, Vec<Value>)> = Vec::new();
|
||||
let mut roots = Vec::new();
|
||||
for row in rows {
|
||||
let depth = row.path.len().saturating_sub(1);
|
||||
while open.last().is_some_and(|(d, _, _)| *d >= depth) {
|
||||
close_row(&mut open, &mut roots);
|
||||
}
|
||||
let path: Vec<usize> = row.path.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
|
||||
// Only what differs from the defaults (black, regular), as the PDF itself stores it.
|
||||
if let Some(color) = row.color {
|
||||
fields["color"] = json!(color);
|
||||
}
|
||||
if row.bold {
|
||||
fields["bold"] = json!(true);
|
||||
}
|
||||
if row.italic {
|
||||
fields["italic"] = json!(true);
|
||||
}
|
||||
if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) {
|
||||
fields["destination"] = destination;
|
||||
}
|
||||
open.push((depth, fields, Vec::new()));
|
||||
}
|
||||
while !open.is_empty() {
|
||||
close_row(&mut open, &mut roots);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page
|
||||
/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its
|
||||
/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the
|
||||
/// reader's position and zoom). `None` for a destination without a type.
|
||||
pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option<Value> {
|
||||
use pdfcraft_render::DestView;
|
||||
let points = |left: Option<f32>, top: Option<f32>| {
|
||||
let [x, y] = page.dest_fraction(left, top, 0);
|
||||
let round = |v: f32| f64::from((v * 100.0).round() / 100.0);
|
||||
(x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height)))
|
||||
};
|
||||
Some(match view {
|
||||
DestView::Top => return None,
|
||||
DestView::Xyz { left, top, zoom } => {
|
||||
let (x, y) = points(*left, *top);
|
||||
json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) })
|
||||
}
|
||||
DestView::Fit => json!({ "fit": "Fit" }),
|
||||
DestView::FitH { top } => {
|
||||
let (x, y) = points(None, *top);
|
||||
json!({ "fit": "FitH", "left": x, "top": y })
|
||||
}
|
||||
DestView::FitV { left } => {
|
||||
let (x, y) = points(*left, None);
|
||||
json!({ "fit": "FitV", "left": x, "top": y })
|
||||
}
|
||||
DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes the innermost open row: it joins the row above it as a child, or the top level.
|
||||
fn close_row(open: &mut Vec<(usize, Value, Vec<Value>)>, roots: &mut Vec<Value>) {
|
||||
if let Some((_, mut fields, children)) = open.pop() {
|
||||
fields["children"] = Value::Array(children);
|
||||
match open.last_mut() {
|
||||
Some((_, _, siblings)) => siblings.push(fields),
|
||||
None => roots.push(fields),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn one_based(pages: &[i64]) -> Result<Vec<usize>> {
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The document's security as the next save writes it (never includes passwords).
|
||||
pub(crate) fn security(d: &Document) -> Value {
|
||||
match d.security_summary() {
|
||||
None => json!({ "protected": false }),
|
||||
Some(s) => {
|
||||
let p = s.permissions;
|
||||
json!({
|
||||
"protected": true,
|
||||
"method": s.method,
|
||||
"pending": s.pending,
|
||||
"printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" },
|
||||
"modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(),
|
||||
"fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What every tool that changes a document returns.
|
||||
pub(crate) fn summary(d: &Document) -> Value {
|
||||
json!({
|
||||
"doc": d.id.0,
|
||||
"name": d.name,
|
||||
"path": d.path,
|
||||
"pages": d.info.pages.len(),
|
||||
"dirty": d.dirty,
|
||||
"editable": d.read_only_reason.is_none(),
|
||||
"read_only_reason": d.read_only_reason,
|
||||
"encrypted": d.info.encrypted,
|
||||
"undo": d.can_undo(),
|
||||
"redo": d.can_redo(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn info(d: &Document) -> Value {
|
||||
let i = &d.info;
|
||||
let page1 = |p: usize| p + 1;
|
||||
let mut security = security(d);
|
||||
if let Some(s) = d.security_summary() {
|
||||
security["opened_as_owner"] = json!(s.owner);
|
||||
}
|
||||
json!({
|
||||
"document": summary(d),
|
||||
"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,
|
||||
// Where the document opens (/OpenAction, named destinations resolved): 1-based page and
|
||||
// destination, null without one.
|
||||
"open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })),
|
||||
// XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic"
|
||||
// (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed).
|
||||
"xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
"xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })),
|
||||
// A script of the form ran too long and was abandoned: its scripts no longer run in this
|
||||
// document (calculations, buttons) until it is opened again.
|
||||
"xfa_scripts_off": d.xfa_scripts_off(),
|
||||
// What was rewritten from, or could not be written to, the XFA data.
|
||||
"xfa_warnings": d.xfa_warnings,
|
||||
"security": security,
|
||||
"pages": i.pages.iter().enumerate().map(|(n, p)| json!({
|
||||
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,
|
||||
})).collect::<Vec<_>>(),
|
||||
"outline": outline(&i.outline),
|
||||
"outline_more": i.outline_more,
|
||||
// Rectangles use the tools' convention (top-left of the displayed page, like
|
||||
// comment_list and link_list), not raw PDF user space, so they can be fed back to
|
||||
// geometry-taking tools (#129).
|
||||
"annotations": i.annotations.iter().map(|a| json!({
|
||||
"page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents,
|
||||
"modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to,
|
||||
"rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))),
|
||||
})).collect::<Vec<_>>(),
|
||||
"fields": i.fields.iter().map(|f| json!({
|
||||
"name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1),
|
||||
"tooltip": f.tooltip, "has_actions": f.has_actions,
|
||||
})).collect::<Vec<_>>(),
|
||||
"links": i.links.iter().map(|l| json!({
|
||||
"page": page1(l.page), "rect": view_rect(i, l.page, l.rect),
|
||||
"target": match &l.target {
|
||||
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
|
||||
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
|
||||
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
|
||||
"layers": changes.iter().map(|(op, ocg)| json!({
|
||||
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
|
||||
"state": match op {
|
||||
pdfcraft_render::LayerOp::On => "on",
|
||||
pdfcraft_render::LayerOp::Off => "off",
|
||||
pdfcraft_render::LayerOp::Toggle => "toggle",
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"preserve_rb": preserve_rb,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
|
||||
"action": if *launch { "Launch" } else { "GoToR" },
|
||||
"remote_document": file,
|
||||
"destination": match destination {
|
||||
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
|
||||
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
|
||||
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
|
||||
},
|
||||
"new_window": new_window,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
|
||||
},
|
||||
})).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!({
|
||||
"page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration,
|
||||
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
|
||||
"display": t.display,
|
||||
})).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// `substitute` names the face standing in for a program the document does not embed, and
|
||||
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
|
||||
// `substitute` with a non-null `substitution` means no face was available at all, so
|
||||
// nothing is drawn for that font -- which is the answer to "why is my text missing?".
|
||||
"fonts": i.fonts.iter().map(|f| json!({
|
||||
"name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding,
|
||||
"substitute": f.substitute, "substitution": f.substitution,
|
||||
})).collect::<Vec<_>>(),
|
||||
"warnings": i.warnings,
|
||||
"repairs": d.repair_log(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the
|
||||
/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports
|
||||
/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page).
|
||||
/// Public so `pdfcraft-cli` reports the same rectangles as the tools.
|
||||
pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
|
||||
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
+31
-3283
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
use super::dispatch::Content;
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, one_based};
|
||||
use super::*;
|
||||
|
||||
/// Default and maximum resolution for `page_render`.
|
||||
pub(crate) const DEFAULT_DPI: f64 = 96.0;
|
||||
|
||||
pub(crate) const MAX_DPI: f64 = 600.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).
|
||||
pub(crate) const FIND_FIRST_BATCH: usize = 8;
|
||||
|
||||
const FIND_MAX_BATCH: usize = 128;
|
||||
|
||||
type PageTexts = Vec<(usize, std::result::Result<Arc<PageText>, String>)>;
|
||||
|
||||
pub(crate) const FIND_MAX_WARNINGS: usize = 100;
|
||||
|
||||
pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024;
|
||||
|
||||
impl Automation {
|
||||
// ---- pages and text ----------------------------------------------------------------------
|
||||
|
||||
/// 1-based page list → validated 0-based indices.
|
||||
pub(crate) fn pages(&self, a: &Args, key: &str) -> Result<Vec<usize>> {
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = one_based(&a.ints(key)?)?;
|
||||
if pages.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(format!("{key} must list at least one page")));
|
||||
}
|
||||
if let Some(p) = pages.iter().find(|p| **p >= n) {
|
||||
return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1)));
|
||||
}
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
/// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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();
|
||||
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(Some(at as usize - 1))
|
||||
}
|
||||
|
||||
pub(crate) fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
|
||||
// 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();
|
||||
}
|
||||
Ok(&mut entry.1)
|
||||
}
|
||||
|
||||
pub(crate) fn page_render(&mut self, a: &Args) -> Result<Content> {
|
||||
let id = self.doc(a)?.id;
|
||||
let page = self.page(a)?;
|
||||
let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI);
|
||||
if !(1.0..=MAX_DPI).contains(&dpi) {
|
||||
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
|
||||
}
|
||||
let scale = (dpi / 72.0) as f32;
|
||||
let block = a.opt_int("hide_text_block")?;
|
||||
let added = a.opt_int("hide_added_text")?;
|
||||
if block.is_some() && added.is_some() {
|
||||
return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into()));
|
||||
}
|
||||
let out = if let Some(block) = block {
|
||||
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()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?;
|
||||
self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
} else {
|
||||
self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false })
|
||||
};
|
||||
if let Some(e) = out.error {
|
||||
return Err(failed(format!("page {} could not be rendered: {e}", page + 1)));
|
||||
}
|
||||
let data = encode_png(out.width, out.height, &out.rgba)?;
|
||||
Ok(Content::Png { data, width: out.width, height: out.height })
|
||||
}
|
||||
|
||||
/// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels
|
||||
/// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas:
|
||||
/// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted
|
||||
/// work on every scroll. It uses the same renderer, and the same per-document cache, as
|
||||
/// `page_render`.
|
||||
pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> {
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) {
|
||||
return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0)));
|
||||
}
|
||||
let id = DocId(doc);
|
||||
let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len();
|
||||
if page < 1 || page > n {
|
||||
return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages")));
|
||||
}
|
||||
let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false });
|
||||
if let Some(e) = out.error {
|
||||
return Err(failed(format!("page {page} could not be rendered: {e}")));
|
||||
}
|
||||
Ok((out.width, out.height, out.rgba))
|
||||
}
|
||||
|
||||
pub(crate) fn page(&self, a: &Args) -> Result<usize> {
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let p = a.int("page")?;
|
||||
if p < 1 || p as usize > n {
|
||||
return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages")));
|
||||
}
|
||||
Ok(p as usize - 1)
|
||||
}
|
||||
|
||||
/// The text of `pages` (0-based), from the cache or extracted in parallel.
|
||||
pub(crate) fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result<Vec<Arc<PageText>>> {
|
||||
self.page_text_results(id, pages, &mut extract_parallel)?
|
||||
.into_iter()
|
||||
.map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep per-page failures until the caller chooses strict or partial-result semantics.
|
||||
pub(crate) fn page_text_results(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
pages: &[usize],
|
||||
extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
|
||||
) -> Result<PageTexts> {
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
|
||||
let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len());
|
||||
let password: Option<Arc<str>> = doc.password.as_deref().map(Arc::from);
|
||||
self.texts.prepare(id, &bytes);
|
||||
let mut results = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
for &p in pages {
|
||||
if p >= n {
|
||||
results.insert(p, Err("page is out of range".to_owned()));
|
||||
} else if let Some(text) = self.texts.get(id, p) {
|
||||
results.insert(p, Ok(text));
|
||||
} else {
|
||||
missing.push(p);
|
||||
}
|
||||
}
|
||||
missing.sort_unstable();
|
||||
missing.dedup();
|
||||
if !missing.is_empty() {
|
||||
for (p, text) in extract(&bytes, password, &missing) {
|
||||
if let Ok(text) = &text {
|
||||
self.texts.insert(id, p, &bytes, text.clone());
|
||||
}
|
||||
results.insert(p, text);
|
||||
}
|
||||
}
|
||||
// The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A
|
||||
// worker that fails to return a page is an explicit failure, never an empty success.
|
||||
Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn text_word_count(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::word_count::{self, Counts, Options};
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
let options = Options {
|
||||
exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false),
|
||||
exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false),
|
||||
exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false),
|
||||
};
|
||||
let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?;
|
||||
let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces });
|
||||
Ok(json!({
|
||||
"total": counts(report.total),
|
||||
"pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::<Vec<_>>(),
|
||||
"pages_without_text": report.pages_without_text,
|
||||
"unclassified_structure": report.unclassified_structure,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn text_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let id = doc.id;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
// Column select (#740): only the text inside the rectangle, row by row.
|
||||
let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32));
|
||||
let texts = self.page_texts(id, &pages)?;
|
||||
let text = |t: &PageText| match rect {
|
||||
Some(r) => t.column_text(&t.glyphs_in(r)),
|
||||
None => t.plain_text(),
|
||||
};
|
||||
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect();
|
||||
Ok(json!({ "pages": out }))
|
||||
}
|
||||
|
||||
/// Matches in document order, extracting page text in growing batches and stopping as soon as
|
||||
/// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document
|
||||
/// (#971). One match past the limit supplies `next`. Reached extraction failures are reported
|
||||
/// as bounded warnings; `complete` requires both no continuation and no failed pages.
|
||||
pub(crate) fn text_find(&mut self, a: &Args) -> Result<Value> {
|
||||
self.text_find_with(a, &mut extract_parallel)
|
||||
}
|
||||
|
||||
pub(crate) fn text_find_with(
|
||||
&mut self,
|
||||
a: &Args,
|
||||
extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
|
||||
) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, n) = (doc.id, doc.info.pages.len());
|
||||
let query = a.str("query")?;
|
||||
let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX);
|
||||
let start = match a.opt_int("start_page")? {
|
||||
None => 0,
|
||||
Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0),
|
||||
Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))),
|
||||
};
|
||||
let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX);
|
||||
let mut matches = Vec::new();
|
||||
let mut next = None;
|
||||
let mut warnings = Vec::new();
|
||||
let mut warning_count = 0usize;
|
||||
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
|
||||
'search: while first < n {
|
||||
let end = first.saturating_add(batch).min(n);
|
||||
let pages: Vec<usize> = (first..end).collect();
|
||||
let texts = self.page_text_results(id, &pages, extract)?;
|
||||
for (p, text) in texts {
|
||||
let text = match text {
|
||||
Ok(text) => text,
|
||||
Err(mut error) => {
|
||||
warning_count += 1;
|
||||
if warnings.len() < FIND_MAX_WARNINGS {
|
||||
let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len()));
|
||||
let truncated = end < error.len();
|
||||
error.truncate(end);
|
||||
warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let skip_here = if p == start { skip } else { 0 };
|
||||
// One extra match proves that the caller needs a continuation. Include skipped
|
||||
// matches on the starting page when bounding the temporary per-page result.
|
||||
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1);
|
||||
for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) {
|
||||
if matches.len() == limit {
|
||||
next = Some(json!({ "start_page": p + 1, "skip": k }));
|
||||
break 'search;
|
||||
}
|
||||
matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) }));
|
||||
}
|
||||
}
|
||||
first = end;
|
||||
batch = batch.saturating_mul(2).min(FIND_MAX_BATCH);
|
||||
}
|
||||
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches,
|
||||
"warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings });
|
||||
if let (Some(next), Some(o)) = (next, out.as_object_mut()) {
|
||||
o.insert("next".into(), next);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn command_list(&self, a: &Args) -> Result<Value> {
|
||||
let active = match a.opt_int("doc")? {
|
||||
Some(_) => Some(self.doc(a)?.id),
|
||||
None => None,
|
||||
};
|
||||
let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase();
|
||||
let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false);
|
||||
let list: Vec<Value> = commands::COMMANDS
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"label": commands::current_label(c, &self.session, active),
|
||||
"menu": c.menu,
|
||||
"shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))),
|
||||
"enabled": commands::is_enabled(c, &self.session, active),
|
||||
"tool": tools::tool_for_command(c.id),
|
||||
"params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let list: Vec<Value> = list
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
(!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true))
|
||||
&& (filter.is_empty()
|
||||
|| ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter))))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "commands": list }))
|
||||
}
|
||||
}
|
||||
|
||||
/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same).
|
||||
pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>> {
|
||||
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
|
||||
}
|
||||
|
||||
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
|
||||
pub(crate) fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> PageTexts {
|
||||
let extract = |r: &mut PageRenderer, p: usize| {
|
||||
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
match out.error {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(out.text.unwrap_or_default()),
|
||||
}
|
||||
};
|
||||
let config = RenderConfig { password, ..Default::default() };
|
||||
let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) };
|
||||
// Small jobs aren't worth a second parse of the document.
|
||||
if workers == 1 || pages.len() < 8 {
|
||||
let mut r = PageRenderer::new(bytes.clone(), config);
|
||||
return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect();
|
||||
}
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let mut out: PageTexts = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|_| {
|
||||
s.spawn(|| {
|
||||
let mut r = PageRenderer::new(bytes.clone(), config.clone());
|
||||
let mut done = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some(p) = pages.get(i) else { break };
|
||||
done.push((*p, extract(&mut r, *p)));
|
||||
}
|
||||
done
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect()
|
||||
});
|
||||
out.sort_by_key(|(p, _)| *p);
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use super::error::{Result, failed};
|
||||
use super::*;
|
||||
|
||||
impl Automation {
|
||||
// ---- paths -------------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that
|
||||
/// does not exist yet (its nearest existing ancestor must be inside the root).
|
||||
///
|
||||
/// Every path outside the root gets the same refusal, so a confined client can't learn what
|
||||
/// exists out there (#136): `..` is resolved by name first, another network share or device
|
||||
/// namespace is refused without touching it, and the deepest existing ancestor (links
|
||||
/// followed) must be inside the root before anything about the rest is reported.
|
||||
pub(crate) fn resolve(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let access = if for_write { file_access::Access::Write } else { file_access::Access::Read };
|
||||
self.resolve_for(path, access, for_write)
|
||||
}
|
||||
|
||||
/// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that
|
||||
/// is only read may still be missing (a path checked up front, whose absence is reported per
|
||||
/// file later). The automation roots, if any, get the last word.
|
||||
pub(crate) fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result<PathBuf> {
|
||||
let target = self.resolve_in_root(path, may_be_missing)?;
|
||||
self.check_roots(path, &target, access)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Hold `target`, which `path` named, to the automation read or write root. Always passes
|
||||
/// without roots.
|
||||
pub(crate) fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> {
|
||||
match &self.roots {
|
||||
Some(roots) => roots.check_path(path, target, access).map_err(failed),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_in_root(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
|
||||
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
|
||||
let joined = lexical(&root.join(p)).ok_or_else(outside)?;
|
||||
if foreign_share(&joined, root) {
|
||||
return Err(outside());
|
||||
}
|
||||
let mut existing = joined.as_path();
|
||||
let mut rest = Vec::new();
|
||||
while !existing.exists() {
|
||||
if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return Err(outside()); // a broken link: going on would tell whether its target exists
|
||||
}
|
||||
match (existing.file_name(), existing.parent()) {
|
||||
(Some(name), Some(parent)) => {
|
||||
rest.push(name);
|
||||
existing = parent;
|
||||
}
|
||||
_ => return Err(outside()), // not even a drive or share that exists
|
||||
}
|
||||
}
|
||||
let mut real = existing.canonicalize().map_err(|_| outside())?;
|
||||
if !real.starts_with(root) {
|
||||
return Err(outside());
|
||||
}
|
||||
if !rest.is_empty() && !for_write {
|
||||
// Missing, below a folder inside the root: say where it was looked for, so a path
|
||||
// written for another working directory can be corrected (#1890), and otherwise
|
||||
// why, as the system reports it. `joined` is under the root, so naming it reveals
|
||||
// nothing outside.
|
||||
real = joined.canonicalize().map_err(|e| {
|
||||
let (looked, root) = (joined.display(), root.display());
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")),
|
||||
_ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")),
|
||||
}
|
||||
})?;
|
||||
if !real.starts_with(root) {
|
||||
return Err(outside()); // it appeared, as a link out, since the check above
|
||||
}
|
||||
return Ok(real);
|
||||
}
|
||||
real.extend(rest.iter().rev());
|
||||
Ok(real)
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does
|
||||
/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb
|
||||
/// above the start of the path.
|
||||
pub(crate) fn lexical(path: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut parts: Vec<Component> = Vec::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => match parts.last() {
|
||||
Some(Component::Normal(_)) => {
|
||||
parts.pop();
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
other => parts.push(other),
|
||||
}
|
||||
}
|
||||
Some(parts.iter().collect())
|
||||
}
|
||||
|
||||
/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators,
|
||||
/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots
|
||||
/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`.
|
||||
pub(crate) fn child(dir: &Path, name: &str) -> PathBuf {
|
||||
let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
|
||||
if safe.chars().all(|c| c == '.') {
|
||||
safe.insert(0, '_');
|
||||
}
|
||||
dir.join(safe)
|
||||
}
|
||||
|
||||
/// `<stem>.pdf` in `dir`, or `<stem> (2).pdf` and so on when that name is taken.
|
||||
pub(crate) fn unused(dir: &Path, stem: &str) -> PathBuf {
|
||||
let first = child(dir, &format!("{stem}.pdf"));
|
||||
if !first.exists() {
|
||||
return first;
|
||||
}
|
||||
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
|
||||
}
|
||||
|
||||
/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`).
|
||||
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap.
|
||||
pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path)?;
|
||||
if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_INPUT {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn read_input(path: &Path) -> Result<Vec<u8>> {
|
||||
read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Write and sync a temporary file in the same directory, then rename over the target.
|
||||
/// This flushes staged contents; it does not promise parent-directory durability after a
|
||||
/// power loss. Filesystems that refuse sync cause the save to fail before publication.
|
||||
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
write_atomic_with(path, bytes, staging_suffixes())
|
||||
}
|
||||
|
||||
/// [`write_atomic`], trying the staging names that `suffixes` give.
|
||||
pub(crate) fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item = u64>) -> Result<()> {
|
||||
write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all)
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic_synced(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> Result<()> {
|
||||
// A folder can't be replaced, and staging beside it could land outside the root: "." names
|
||||
// the root itself, whose parent isn't ours to write in.
|
||||
if path.is_dir() {
|
||||
return Err(failed(format!("{}: is a folder, not a file", path.display())));
|
||||
}
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
// The writer itself is the platform crate's, shared with the desktop app and the CLI
|
||||
// (#2166): the staging file is written, flushed (delayed writeback failures surface before
|
||||
// the destination is replaced or a document marked clean), closed and renamed; a failure at
|
||||
// any step removes it and leaves the destination as it was.
|
||||
pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync)
|
||||
.map_err(|e| failed(format!("{}: {e}", path.display())))
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
use super::*;
|
||||
use pdfcraft_platform::staging::STAGING_ATTEMPTS;
|
||||
|
||||
/// `n` pages, page i showing "Page i".
|
||||
fn numbered_pages(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn json(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args).map(|mut c| c.remove(0)) {
|
||||
Ok(Content::Json(v)) => v,
|
||||
other => panic!("{tool} {args}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_document(a: &mut Automation, pages: usize) -> DocId {
|
||||
a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() {
|
||||
let mut a = Automation::new();
|
||||
a.texts = TextCache::new(16 * 1024);
|
||||
let first = search_document(&mut a, 200);
|
||||
let second = search_document(&mut a, 200);
|
||||
for id in [first, second] {
|
||||
let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" }));
|
||||
assert_eq!(result["complete"], true);
|
||||
assert!(a.texts.bytes() <= 16 * 1024);
|
||||
assert!(a.texts.len() < 200, "an exhaustive search must not retain every page");
|
||||
let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 }));
|
||||
assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches");
|
||||
eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes());
|
||||
}
|
||||
assert!(a.texts.get(first, 0).is_none());
|
||||
let text = a.page_texts(first, &[0]).unwrap().remove(0);
|
||||
assert_eq!(text.plain_text(), "Page 1");
|
||||
let again = a.page_texts(first, &[0]).unwrap().remove(0);
|
||||
assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage");
|
||||
assert!(a.texts.bytes() <= 16 * 1024);
|
||||
json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] }));
|
||||
let edited = a.page_texts(first, &[0]).unwrap().remove(0);
|
||||
assert_eq!(edited.plain_text(), "Page 2");
|
||||
assert!(!Arc::ptr_eq(&text, &edited));
|
||||
json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true }));
|
||||
json(&mut a, "doc_close", json!({ "doc": second.0 }));
|
||||
assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 3);
|
||||
let error = a.page_texts(id, &[0, 3, 1]).unwrap_err();
|
||||
assert!(error.to_string().contains("page 4: page is out of range"));
|
||||
assert!(a.texts.get(id, 0).is_some());
|
||||
assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained");
|
||||
let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap();
|
||||
assert!(results[0].1.is_err(), "a missing worker result must not become an empty page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_keeps_matches_around_failed_pages_and_continuations() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 16);
|
||||
let mut extract = |bytes: &Arc<Vec<u8>>, password, pages: &[usize]| {
|
||||
extract_parallel(bytes, password, pages)
|
||||
.into_iter()
|
||||
.map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result }))
|
||||
.collect()
|
||||
};
|
||||
let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap();
|
||||
assert_eq!(all["count"], 14);
|
||||
assert_eq!(all["complete"], false);
|
||||
assert_eq!(all["warning_count"], 2);
|
||||
assert_eq!(all["warnings"][0]["page"], 3);
|
||||
assert_eq!(all["warnings"][1]["page"], 7);
|
||||
assert!(all.get("next").is_none());
|
||||
assert_eq!(all["matches"][13]["page"], 16);
|
||||
assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached");
|
||||
let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 });
|
||||
let mut matches = Vec::new();
|
||||
let mut warned = Vec::new();
|
||||
loop {
|
||||
let part = a.text_find_with(&Args(&args), &mut extract).unwrap();
|
||||
matches.extend(part["matches"].as_array().unwrap().iter().cloned());
|
||||
warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone()));
|
||||
if !part["warnings"].as_array().unwrap().is_empty() {
|
||||
assert_eq!(part["complete"], false);
|
||||
}
|
||||
let Some(next) = part.get("next") else { break };
|
||||
args["start_page"] = next["start_page"].clone();
|
||||
args["skip"] = next["skip"].clone();
|
||||
}
|
||||
assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated");
|
||||
assert_eq!(warned, vec![json!(3), json!(7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_bounds_warning_volume_and_does_not_reach_distant_failures() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 200);
|
||||
let first = a
|
||||
.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| {
|
||||
assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end");
|
||||
extract_parallel(bytes, password, pages)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(first["count"], 1);
|
||||
assert_eq!(first["warning_count"], 0);
|
||||
assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 }));
|
||||
a.texts.remove(&id);
|
||||
let failures = a
|
||||
.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| {
|
||||
pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failures["count"], 0);
|
||||
assert_eq!(failures["complete"], false);
|
||||
assert!(failures.get("next").is_none());
|
||||
assert_eq!(failures["warning_count"], 200);
|
||||
assert_eq!(failures["warnings_truncated"], true);
|
||||
let warnings = failures["warnings"].as_array().unwrap();
|
||||
assert_eq!(warnings.len(), FIND_MAX_WARNINGS);
|
||||
assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone();
|
||||
let extracted = |a: &Automation| a.texts.len();
|
||||
|
||||
// #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages
|
||||
// aren't extracted or cached.
|
||||
let first = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 1", "limit": 1 }));
|
||||
assert_eq!(first["count"], 1);
|
||||
assert_eq!(first["matches"][0]["page"], 1);
|
||||
assert_eq!(first["complete"], false, "{first}");
|
||||
assert_eq!(first["next"], json!({ "start_page": 10, "skip": 0 }));
|
||||
assert!(extracted(&a) <= 3 * FIND_FIRST_BATCH, "{} pages extracted for one match", extracted(&a));
|
||||
|
||||
// Following `next` returns every match exactly once, in order, and then says it's complete.
|
||||
// "1" appears twice on pages like 11 and 101, so continuing has to skip within a page.
|
||||
let all = json(&mut a, "text_find", json!({ "doc": doc, "query": "1", "limit": 10_000 }));
|
||||
assert_eq!(all["complete"], true);
|
||||
assert!(all.get("next").is_none());
|
||||
let mut pieced = Vec::new();
|
||||
let mut args = json!({ "doc": doc, "query": "1", "limit": 3 });
|
||||
loop {
|
||||
let found = json(&mut a, "text_find", args.clone());
|
||||
pieced.extend(found["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())));
|
||||
if found["complete"] == true {
|
||||
break;
|
||||
}
|
||||
args["start_page"] = found["next"]["start_page"].clone();
|
||||
args["skip"] = found["next"]["skip"].clone();
|
||||
}
|
||||
let expected: Vec<_> = all["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())).collect();
|
||||
assert_eq!(all["count"], expected.len());
|
||||
assert_eq!(pieced, expected);
|
||||
|
||||
// A search that runs to the end below its limit is complete; a start page past the end is refused.
|
||||
let few = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 200", "start_page": 150 }));
|
||||
assert_eq!((few["count"].clone(), few["complete"].clone()), (json!(1), json!(true)));
|
||||
assert!(a.call("text_find", &json!({ "doc": doc, "query": "x", "start_page": 201 })).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() {
|
||||
let base = std::env::temp_dir();
|
||||
let l = |rel: &str| lexical(&base.join(rel));
|
||||
assert_eq!(l("a/./b/../c"), Some(base.join("a").join("c")));
|
||||
assert_eq!(l("missing/../../x"), lexical(&base.join("..").join("x")));
|
||||
assert_eq!(l("a/.."), Some(lexical(&base).unwrap()));
|
||||
let deep = "../".repeat(base.components().count() + 1);
|
||||
assert_eq!(lexical(&base.join(&deep)), None);
|
||||
assert_eq!(lexical(Path::new("..")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_from_documents_become_one_plain_file_name() {
|
||||
let dir = std::env::temp_dir().join("out");
|
||||
for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] {
|
||||
let p = child(&dir, name);
|
||||
assert_eq!(p.parent(), Some(dir.as_path()), "{name:?} -> {}", p.display());
|
||||
assert_eq!(p.components().count(), dir.components().count() + 1, "{name:?} -> {}", p.display());
|
||||
}
|
||||
assert_eq!(child(&dir, "ok.png"), dir.join("ok.png"));
|
||||
assert_eq!(child(&dir, "../x.png"), dir.join(".._x.png"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn other_shares_and_device_paths_are_refused_by_name() {
|
||||
let local = Path::new(r"\\?\C:\work\root");
|
||||
for p in [
|
||||
r"\\host\share\x.pdf",
|
||||
"//host/share/x.pdf",
|
||||
r"\/host/share/x.pdf",
|
||||
r"\\?\UNC\host\share\x.pdf",
|
||||
r"\\.\pipe\x",
|
||||
r"\\.\C:\work\root\x.pdf",
|
||||
r"\\?\GLOBALROOT\Device\x",
|
||||
] {
|
||||
assert!(foreign_share(Path::new(p), local), "{p}");
|
||||
}
|
||||
for p in [r"C:\work\root\x.pdf", r"c:\elsewhere\x.pdf", r"\\?\C:\work\root\x.pdf", r"D:\x.pdf", r"C:x.pdf"] {
|
||||
assert!(!foreign_share(Path::new(p), local), "{p}");
|
||||
}
|
||||
// A root on a share accepts that share, however it is spelled, and nothing else.
|
||||
let shared = Path::new(r"\\?\UNC\Server\Docs\root");
|
||||
assert!(!foreign_share(Path::new(r"\\server\docs\root\x.pdf"), shared));
|
||||
assert!(!foreign_share(Path::new(r"\\?\UNC\SERVER\DOCS\x.pdf"), shared));
|
||||
assert!(foreign_share(Path::new(r"\\server\other\x.pdf"), shared));
|
||||
assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared));
|
||||
}
|
||||
|
||||
/// A fresh, empty folder for one staging test.
|
||||
fn staging_dir(test: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn staged(dir: &Path, suffix: u64) -> PathBuf {
|
||||
dir.join(format!(".out.pdf.{suffix:016x}.pdfcraft-tmp"))
|
||||
}
|
||||
|
||||
fn read(p: &Path) -> String {
|
||||
std::fs::read_to_string(p).unwrap()
|
||||
}
|
||||
|
||||
/// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an
|
||||
/// administrator for it).
|
||||
fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::os::windows::fs::symlink_file(target, link)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display())))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_never_writes_through_a_file_planted_at_its_name() {
|
||||
let dir = staging_dir("planted");
|
||||
// A file elsewhere that a planted link points at.
|
||||
let outside = dir.join("outside.txt");
|
||||
std::fs::write(&outside, "PRECIOUS").unwrap();
|
||||
let target = dir.join("out.pdf");
|
||||
// A hard link needs no privileges on any system, and writing to it writes to `outside`.
|
||||
std::fs::hard_link(&outside, staged(&dir, 1)).unwrap();
|
||||
std::fs::write(staged(&dir, 2), "PLANTED").unwrap();
|
||||
write_atomic_with(&target, b"NEW", [1, 2, 3]).unwrap();
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert_eq!(read(&outside), "PRECIOUS");
|
||||
assert_eq!(read(&staged(&dir, 1)), "PRECIOUS");
|
||||
assert_eq!(read(&staged(&dir, 2)), "PLANTED");
|
||||
assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_never_writes_through_a_symbolic_link_planted_at_its_name() {
|
||||
let dir = staging_dir("symlink");
|
||||
let outside = dir.join("outside.txt");
|
||||
std::fs::write(&outside, "PRECIOUS").unwrap();
|
||||
let target = dir.join("out.pdf");
|
||||
if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) {
|
||||
eprintln!("symbolic links not checked: {e}");
|
||||
return;
|
||||
}
|
||||
write_atomic_with(&target, b"NEW", [1, 2]).unwrap();
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert_eq!(read(&outside), "PRECIOUS");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_never_creates_a_file_through_a_dangling_link_at_its_name() {
|
||||
let dir = staging_dir("dangling");
|
||||
let unborn = dir.join("created-through-a-link.txt");
|
||||
let target = dir.join("out.pdf");
|
||||
if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) {
|
||||
eprintln!("symbolic links not checked: {e}");
|
||||
return;
|
||||
}
|
||||
write_atomic_with(&target, b"NEW", [1, 2]).unwrap();
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert!(!unborn.exists(), "nothing was created through the dangling link");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_gives_up_rather_than_reuse_a_taken_name() {
|
||||
let dir = staging_dir("taken");
|
||||
let target = dir.join("out.pdf");
|
||||
std::fs::write(&target, "OLD").unwrap();
|
||||
for s in 1..=STAGING_ATTEMPTS as u64 {
|
||||
std::fs::write(staged(&dir, s), "PLANTED").unwrap();
|
||||
}
|
||||
assert!(write_atomic_with(&target, b"NEW", 1..).is_err());
|
||||
assert_eq!(read(&target), "OLD");
|
||||
for s in 1..=STAGING_ATTEMPTS as u64 {
|
||||
assert_eq!(read(&staged(&dir, s)), "PLANTED");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() {
|
||||
for exists in [false, true] {
|
||||
let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" });
|
||||
let target = dir.join("out.pdf");
|
||||
if exists {
|
||||
std::fs::write(&target, "OLD").unwrap();
|
||||
}
|
||||
let result = write_atomic_synced(&target, b"NEW", [1], |_| {
|
||||
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing");
|
||||
if exists {
|
||||
assert_eq!(read(&target), "OLD", "sync precedes publication");
|
||||
}
|
||||
Err(std::io::Error::other("injected storage sync failure"))
|
||||
});
|
||||
assert!(result.is_err(), "sync failure must refuse publication");
|
||||
assert!(result.unwrap_err().to_string().contains("injected storage sync failure"));
|
||||
if exists {
|
||||
assert_eq!(read(&target), "OLD");
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
} else {
|
||||
assert!(!target.exists());
|
||||
assert!(listing(&dir).is_empty());
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_save_bytes_never_reach_publication_or_mark_saved() {
|
||||
let dir = staging_dir("invalid-save-bytes");
|
||||
let target = dir.join("out.pdf");
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = search_document(&mut a, 2);
|
||||
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
|
||||
let old = std::fs::read(&target).unwrap();
|
||||
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
|
||||
let before = summary(a.session.get(id).unwrap());
|
||||
// Exercise both parse failure and a valid PDF that loses a page. This does not
|
||||
// depend on a malformed input remaining unrepaired by future reader improvements.
|
||||
for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] {
|
||||
let mut published = false;
|
||||
let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| {
|
||||
published = true;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err(), "invalid output must be refused");
|
||||
assert!(result.unwrap_err().to_string().contains("could not be reopened"));
|
||||
assert!(!published, "validation must precede the writer");
|
||||
assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), old);
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
}
|
||||
json(&mut a, "doc_save", json!({"doc": id.0}));
|
||||
assert!(!a.session.get(id).unwrap().dirty);
|
||||
assert_ne!(std::fs::read(&target).unwrap(), old);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
|
||||
let dir = staging_dir("sync-dirty");
|
||||
let target = dir.join("out.pdf");
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = search_document(&mut a, 2);
|
||||
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
|
||||
let old = std::fs::read(&target).unwrap();
|
||||
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
|
||||
let args = json!({"doc": id.0});
|
||||
let before = summary(a.doc(&Args(&args)).unwrap());
|
||||
let result = a.doc_save_with(&Args(&args), |path, bytes| {
|
||||
write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure")))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
let doc = a.doc(&Args(&args)).unwrap();
|
||||
assert!(doc.dirty);
|
||||
assert_eq!(summary(doc), before, "failed save must not reset history or saved state");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), old);
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
json(&mut a, "doc_save", args.clone());
|
||||
assert!(!a.doc(&Args(&args)).unwrap().dirty);
|
||||
assert_ne!(std::fs::read(&target).unwrap(), old);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_staging_sync_precedes_publication() {
|
||||
let dir = staging_dir("sync-success");
|
||||
let target = dir.join("out.pdf");
|
||||
std::fs::write(&target, "OLD").unwrap();
|
||||
let mut called = false;
|
||||
write_atomic_synced(&target, b"NEW", [1], |file| {
|
||||
called = true;
|
||||
assert_eq!(read(&target), "OLD");
|
||||
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW");
|
||||
file.sync_all()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(called);
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The names in a folder: what a test can see was left behind.
|
||||
fn listing(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_names_differ_between_saves_and_fit_long_names() {
|
||||
let suffixes: std::collections::HashSet<u64> = staging_suffixes().take(64).collect();
|
||||
assert_eq!(suffixes.len(), 64);
|
||||
assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names");
|
||||
// 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name
|
||||
// must be too (on Linux the whole name in it would be 275 bytes).
|
||||
let dir = staging_dir("long");
|
||||
let name = format!("{}.pdf", "\u{1F600}".repeat(60));
|
||||
write_atomic(&dir.join(&name), b"NEW").unwrap();
|
||||
assert_eq!(read(&dir.join(&name)), "NEW");
|
||||
assert_eq!(listing(&dir), [name], "no staging file is left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_at_the_staging_name_is_left_alone() {
|
||||
let dir = staging_dir("folder");
|
||||
std::fs::create_dir(staged(&dir, 1)).unwrap();
|
||||
std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap();
|
||||
let target = dir.join("out.pdf");
|
||||
write_atomic_with(&target, b"NEW", [1, 2]).unwrap();
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Windows refuses to replace a read-only file, so the rename fails: the staging file must not
|
||||
/// be left behind.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_failed_rename_removes_the_staging_file() {
|
||||
let dir = staging_dir("readonly");
|
||||
let target = dir.join("out.pdf");
|
||||
std::fs::write(&target, "OLD").unwrap();
|
||||
let mut perms = std::fs::metadata(&target).unwrap().permissions();
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(&target, perms.clone()).unwrap();
|
||||
let e = write_atomic_with(&target, b"NEW", [7]).unwrap_err();
|
||||
assert!(e.to_string().contains(&target.display().to_string()), "the rename failed, not the staging: {e}");
|
||||
assert_eq!(read(&target), "OLD");
|
||||
assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed");
|
||||
#[allow(clippy::permissions_set_readonly_false)]
|
||||
perms.set_readonly(false);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_file_reads_refuse_inputs_exceeding_512_mib() {
|
||||
let dir = staging_dir("capped-read");
|
||||
let huge = dir.join("huge.bin");
|
||||
let file = std::fs::File::create(&huge).unwrap();
|
||||
file.set_len(MAX_INPUT + 1).unwrap();
|
||||
drop(file);
|
||||
let huge_str = huge.to_string_lossy().into_owned();
|
||||
|
||||
let mut a = Automation::new();
|
||||
let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0;
|
||||
for (tool, args) in [
|
||||
("doc_open", json!({ "path": huge_str })),
|
||||
("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })),
|
||||
("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })),
|
||||
("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })),
|
||||
("doc_import_data", json!({ "doc": id, "path": huge_str })),
|
||||
("doc_create", json!({ "from": "text", "path": huge_str })),
|
||||
("doc_create", json!({ "from": "images", "paths": [huge_str] })),
|
||||
("doc_combine", json!({ "paths": [huge_str, huge_str] })),
|
||||
("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })),
|
||||
("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })),
|
||||
("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })),
|
||||
("sign_trust", json!({ "paths": [huge_str] })),
|
||||
("attachment_add", json!({ "doc": id, "path": huge_str })),
|
||||
] {
|
||||
let err = a.call(tool, &args).unwrap_err().to_string();
|
||||
assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -60,7 +60,10 @@ fn color() -> Value {
|
||||
fn comment_ref(mut extra: Value) -> Value {
|
||||
if let Some(props) = extra.as_object_mut() {
|
||||
props.insert("doc".into(), doc());
|
||||
props.insert("id".into(), json!({ "type": "string", "description": "The comment's id (from comment_list)." }));
|
||||
props.insert(
|
||||
"id".into(),
|
||||
json!({ "type": "string", "description": "The comment's id (from comment_list). An id several comments share is refused: pass page and index." }),
|
||||
);
|
||||
props.insert("page".into(), json!({ "type": "integer", "minimum": 1, "description": "With `index`, instead of `id`." }));
|
||||
props.insert(
|
||||
"index".into(),
|
||||
@@ -139,7 +142,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.cmd("file.open")
|
||||
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
|
||||
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.")
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -624,7 +627,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
|
||||
"image": { "type": "string", "description": "Replace the picture with this file." },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
@@ -700,7 +703,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.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.")
|
||||
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
|
||||
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.")
|
||||
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),
|
||||
t(
|
||||
@@ -953,7 +956,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>>. 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.",
|
||||
"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>> <<dd.mm.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(
|
||||
|
||||
@@ -213,6 +213,62 @@ fn doc_info_lists_page_transitions_and_display_times() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `doc_info` says where the document opens: the `/OpenAction` destination, a named one
|
||||
/// resolved (ISO 32000-2 §12.3.2.4), as a 1-based page and a destination in view coordinates.
|
||||
#[test]
|
||||
fn doc_info_reports_the_open_destination() {
|
||||
let dir = workdir("open-destination");
|
||||
let write = |name: &str, catalog: &str| {
|
||||
let objects: Vec<String> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
];
|
||||
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(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join(name), bytes).unwrap();
|
||||
};
|
||||
write("named.pdf", "/OpenAction << /S /GoTo /D /there >> /Dests << /there [4 0 R /XYZ 100 500 2] >>");
|
||||
write("script.pdf", "/OpenAction << /S /JavaScript /JS (1) >>");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "named.pdf" }))["doc"].as_u64().unwrap();
|
||||
let open = ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].clone();
|
||||
assert_eq!(open["page"], 2, "{open}");
|
||||
// y 500 is 292 points below the top of the 792-point page.
|
||||
assert_eq!(
|
||||
(open["destination"]["fit"].as_str(), open["destination"]["left"].as_f64(), open["destination"]["top"].as_f64()),
|
||||
(Some("XYZ"), Some(100.0), Some(292.0)),
|
||||
"{open}"
|
||||
);
|
||||
assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0));
|
||||
// The initial view reads the named destination's page, and choosing page 1 replaces it (ISO
|
||||
// 32000-2 §12.3.2.4): one undo step, kept by the save.
|
||||
let page = |a: &mut Automation, doc: u64| ok(a, "doc_info", json!({ "doc": doc }))["open_destination"]["page"].clone();
|
||||
assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc }))["initial_view"]["page"], 2);
|
||||
assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc, "page": 1 }))["initial_view"]["page"], 1);
|
||||
assert_eq!(page(&mut a, doc), 1);
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(page(&mut a, doc), 2);
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
assert_eq!(page(&mut a, doc), 1);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "named-page1.pdf" }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "named-page1.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page(&mut a, saved), 1);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null());
|
||||
}
|
||||
|
||||
/// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and
|
||||
/// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`,
|
||||
/// so this does not lean on `doc_info` listing them.)
|
||||
@@ -2078,6 +2134,83 @@ fn nested_replies_stay_in_the_listing() {
|
||||
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
|
||||
}
|
||||
|
||||
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
|
||||
/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment.
|
||||
#[test]
|
||||
fn replies_thread_by_reference_when_ids_repeat_across_pages() {
|
||||
let dir = workdir("repeated-ids");
|
||||
// Contributor-original PDF, shaped like PyMuPDF output.
|
||||
std::fs::write(
|
||||
dir.join("fitz.pdf"),
|
||||
b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "fitz.pdf" }))["doc"].as_u64().unwrap();
|
||||
let replies = |a: &mut Automation| -> Vec<(String, Vec<String>)> {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc }));
|
||||
list["comments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut texts: Vec<String> =
|
||||
c["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str().map(String::from)).collect();
|
||||
texts.sort();
|
||||
(c["contents"].as_str().unwrap().to_string(), texts)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string()])]);
|
||||
let refused = err(&mut a, "comment_reply", json!({ "doc": doc, "id": "fitz-A0", "text": "LATER" }));
|
||||
assert!(refused.to_string().contains("several comments"), "{refused}");
|
||||
ok(&mut a, "comment_reply", json!({ "doc": doc, "page": 2, "index": 1, "text": "LATER" }));
|
||||
assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string(), "LATER".to_string()])]);
|
||||
}
|
||||
|
||||
/// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not
|
||||
/// its comment's, as the Comments panel shows it, and is reported on the reply. One whose
|
||||
/// annotation can't be found is not a comment of its own.
|
||||
#[test]
|
||||
fn a_checkmark_on_a_reply_is_not_its_comments() {
|
||||
let dir = workdir("reply-checkmark");
|
||||
// Contributor-original PDF: a comment, a reply, a checkmark and a status on the reply, and a
|
||||
// status whose annotation (object 9) doesn't exist.
|
||||
std::fs::write(
|
||||
dir.join("checked.pdf"),
|
||||
b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (COMMENT) >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (REPLY) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [200 20 220 40] /F 30 /IRT 9 0 R /T (Gamma) /StateModel /Review /State /Rejected >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "checked.pdf" }))["doc"].as_u64().unwrap();
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
assert_eq!(list["count"], 1, "{list}");
|
||||
let c = &list["comments"][0];
|
||||
assert_eq!((&c["marked"], &c["status"]), (&json!(false), &Value::Null), "{c}");
|
||||
assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "the reply, not its states: {c}");
|
||||
let reply = &c["replies"][0];
|
||||
assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protecting_through_tools() {
|
||||
let dir = workdir("protect");
|
||||
@@ -4351,6 +4484,12 @@ fn adding_content_through_tools() {
|
||||
assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}");
|
||||
// Image tools: rotate, flip, crop, replace.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] }));
|
||||
// One side may trim most of the picture; together an axis keeps at least 5 %.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] }));
|
||||
assert!(matches!(
|
||||
a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" }));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true);
|
||||
assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_))));
|
||||
@@ -5722,6 +5861,28 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
assert!(inked(60, 110), "the strokes are drawn");
|
||||
}
|
||||
|
||||
/// Signing a laid-out XFA form's signature box through the tools signs the field named by its
|
||||
/// SOM path, which is how another viewer, laying the form out again, finds the signature; the
|
||||
/// signed file reopens with the signature intact and on that field.
|
||||
#[test]
|
||||
fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
|
||||
let dir = workdir("xfa-sign");
|
||||
std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let som = "form[0].page1[0].sign[0]";
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}");
|
||||
ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" }));
|
||||
let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": signed }));
|
||||
let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}"));
|
||||
assert_eq!(s["field"], som, "{s}");
|
||||
assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_ids_signing_and_validation_through_tools() {
|
||||
let dir = workdir("signing");
|
||||
@@ -6728,12 +6889,79 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an
|
||||
/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves.
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||||
fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() {
|
||||
let dir = workdir("italic-run-word");
|
||||
let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \
|
||||
BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".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 /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("opinion.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" }));
|
||||
let zip = std::fs::read(dir.join("opinion.docx")).unwrap();
|
||||
let mut at = 0;
|
||||
let mut xml = String::new();
|
||||
while zip[at..].starts_with(b"PK\x03\x04") {
|
||||
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
|
||||
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
|
||||
let data = at + 30 + len;
|
||||
if &zip[at + 30..data] == b"word/document.xml" {
|
||||
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
|
||||
break;
|
||||
}
|
||||
at = data + size;
|
||||
}
|
||||
// The paragraphs that hold text, each as its runs: (text, italic).
|
||||
let paragraphs: Vec<Vec<(String, bool)>> = xml
|
||||
.split("<w:p>")
|
||||
.skip(1)
|
||||
.map(|p| {
|
||||
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
|
||||
p.split("<w:r>")
|
||||
.skip(1)
|
||||
.filter_map(|r| Some((r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0.to_string(), r.contains("<w:i/>"))))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|runs| !runs.is_empty())
|
||||
.collect();
|
||||
let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}"));
|
||||
assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}");
|
||||
assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::<String>(), "Before Chevron after.", "{xml}");
|
||||
assert_eq!(sentence.iter().map(|(_, i)| *i).collect::<Vec<_>>(), [false, true, false], "{xml}");
|
||||
assert_eq!(paragraphs.len(), 2, "{xml}");
|
||||
}
|
||||
|
||||
/// Hundreds of calculations run on opening and again after a change through the tools. The
|
||||
/// time limits are raised so that a busy machine does not stop them (the engine's tests check
|
||||
/// that the limits stop them).
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() {
|
||||
let dir = workdir("xfa-many");
|
||||
let (sections, rows) = (10, 20);
|
||||
std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let started = std::time::Instant::now();
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" }));
|
||||
let open_took = started.elapsed();
|
||||
@@ -7156,7 +7384,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
let dir = workdir("xfa-print-buttons");
|
||||
let template = pdfcraft_xfa::fixtures::repeated_print_button_template();
|
||||
std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Raised time limits: a busy machine does not stop the click scripts this checks.
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap();
|
||||
let check = |a: &mut Automation, doc| {
|
||||
let fields = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
@@ -7181,7 +7410,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
let dir = workdir("xfa-scripts");
|
||||
std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Raised time limits: a busy machine does not stop the scripts this checks.
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap();
|
||||
let field = |a: &mut Automation, n: &str| {
|
||||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
@@ -8884,6 +9114,34 @@ fn bookmark_list_reports_colour_and_style() {
|
||||
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
|
||||
}
|
||||
|
||||
/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its
|
||||
/// options, and its page appearance shows it, before and after a save; one without Edit takes
|
||||
/// only its options.
|
||||
#[test]
|
||||
fn form_fill_takes_free_text_only_in_an_editable_combo_box() {
|
||||
let dir = workdir("editable-combo");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let combo = |name: &str, top: f64, editable: bool| json!({ "doc": doc, "page": 1, "type": "combo", "rect": [20, top, 200, top + 22.0], "name": name, "options": ["Apple", "Banana"], "editable": editable });
|
||||
ok(&mut a, "form_add_field", combo("fruit", 20.0, true));
|
||||
ok(&mut a, "form_add_field", combo("veg", 60.0, false));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "fruit": "Cherry" } }));
|
||||
assert!(page_text(&mut a, doc)[0].contains("Cherry"), "the typed value is drawn");
|
||||
let refused = err(&mut a, "form_fill", json!({ "doc": doc, "values": { "veg": "Cherry" } }));
|
||||
assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("no option")), "{refused:?}");
|
||||
// A choice from the list still works in both.
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "veg": "Banana" } }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "filled.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": saved }));
|
||||
let value = |name: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).map(|f| f["value"].clone());
|
||||
assert_eq!(value("fruit"), Some(json!("Cherry")), "{fields}");
|
||||
assert_eq!(value("veg"), Some(json!("Banana")), "{fields}");
|
||||
let text = page_text(&mut a, saved);
|
||||
assert!(text[0].contains("Cherry") && text[0].contains("Banana"), "{text:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its
|
||||
/// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder.
|
||||
#[test]
|
||||
|
||||
@@ -352,6 +352,19 @@ fn links_out_of_a_root_are_refused() {
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
/// #4003: without `--root`, another network share or a device is refused by name through the
|
||||
/// tools too, with the same words as any other path outside a root.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn other_shares_and_devices_are_refused_by_name() {
|
||||
let (mut server, base) = confined("shares", true, true);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
for p in [r"\\.\pipe\pdfcraft-4003.pdf", r"\\pdfcraft-no-such-host.invalid\share\a.pdf", "//pdfcraft-no-such-host.invalid/share/a.pdf"] {
|
||||
refused(&mut server, "doc_open", json!({"path": p}), "outside the read root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": p}), "outside the write root");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = folders("none");
|
||||
|
||||
+17
-3550
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,572 @@
|
||||
use super::reader::find;
|
||||
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
|
||||
use super::xref::{Revision, XrefEntry};
|
||||
use super::*;
|
||||
|
||||
impl Document {
|
||||
pub fn bytes(&self) -> &Arc<Vec<u8>> {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Identity of the original bytes, xref and authenticated decode context. Edits and
|
||||
/// clones preserve it; reopening (including after any save) produces a new identity.
|
||||
pub fn source_identity(&self) -> SourceIdentity {
|
||||
SourceIdentity(Arc::downgrade(&self.source_identity))
|
||||
}
|
||||
|
||||
/// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it
|
||||
/// changes with every edit, so a value derived from the document can be cached against it.
|
||||
pub fn read_state(&self) -> ReadState {
|
||||
ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() }
|
||||
}
|
||||
|
||||
pub fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
/// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save.
|
||||
pub fn set_version(&mut self, version: impl Into<String>) {
|
||||
self.version = version.into();
|
||||
}
|
||||
|
||||
pub fn trailer(&self) -> &Dict {
|
||||
&self.trailer
|
||||
}
|
||||
|
||||
pub fn trailer_mut(&mut self) -> &mut Dict {
|
||||
&mut self.trailer
|
||||
}
|
||||
|
||||
pub fn revisions(&self) -> &[Revision] {
|
||||
&self.revisions
|
||||
}
|
||||
|
||||
/// Position of the `%PDF-` header in the file: 0 unless bytes come before it.
|
||||
pub fn header_offset(&self) -> usize {
|
||||
self.header_offset
|
||||
}
|
||||
|
||||
/// There is no cross-reference chain to append to: the index was rebuilt from object
|
||||
/// headers because the file's own was unusable (see [`Self::repair_log`]), or the document
|
||||
/// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even
|
||||
/// when nothing was edited: for a repaired file that is what puts the repair on disk.
|
||||
pub fn reconstructed(&self) -> bool {
|
||||
self.revisions.is_empty()
|
||||
}
|
||||
|
||||
/// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its
|
||||
/// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it
|
||||
/// was saved. A linearized file's first-page section belongs to the revision it precedes, so
|
||||
/// ends only ever increase. Empty for a reconstructed file.
|
||||
pub fn revision_ends(&self) -> Vec<usize> {
|
||||
let d = &self.data[..];
|
||||
let mut ends: Vec<usize> = Vec::with_capacity(self.revisions.len());
|
||||
for r in self.revisions.iter() {
|
||||
let from = (r.xref_offset as usize).saturating_add(self.header_offset).min(d.len());
|
||||
let Some(at) = d[from..].windows(5).position(|w| w == b"%%EOF") else { continue };
|
||||
let mut end = from + at + 5;
|
||||
while end < d.len() && matches!(d[end], b'\r' | b'\n') && end - (from + at + 5) < 2 {
|
||||
end += 1;
|
||||
}
|
||||
match ends.last_mut() {
|
||||
Some(last) if end <= *last => {}
|
||||
_ => ends.push(end),
|
||||
}
|
||||
}
|
||||
ends
|
||||
}
|
||||
|
||||
pub fn repair_log(&self) -> &[String] {
|
||||
&self.repair_log
|
||||
}
|
||||
|
||||
/// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should
|
||||
/// know about (fidelity: never silent).
|
||||
pub fn note_repair(&mut self, line: String) {
|
||||
let mut log = self.repair_log.as_ref().clone();
|
||||
log.push(line);
|
||||
self.repair_log = Arc::new(log);
|
||||
}
|
||||
|
||||
/// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in
|
||||
/// the trailer (as MuPDF writes it) changes no object, and is an edit all the same.
|
||||
pub fn is_modified(&self) -> bool {
|
||||
!self.overlay.is_empty() || self.encryption_changed
|
||||
}
|
||||
|
||||
/// Object numbers changed since the document was opened (or last saved).
|
||||
pub fn modified_objects(&self) -> Vec<u32> {
|
||||
self.overlay.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Where object `num` is stored in the file (ignoring unsaved edits).
|
||||
pub fn xref_entry(&self, num: u32) -> Option<XrefEntry> {
|
||||
self.entries.lookup(num, &self.data)
|
||||
}
|
||||
|
||||
/// Cross-reference entries this reader did not take at face value, as (byte offset of the
|
||||
/// section, object number): an in-use entry at offset 0, a second and different entry for
|
||||
/// one object in the same section, a reserved cross-reference stream entry type, or a
|
||||
/// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside
|
||||
/// and keeps what it already had for the object. Another reader, the page renderer among
|
||||
/// them, may follow it instead, so the two can then show different objects for one number.
|
||||
/// Code that compares revisions (signature validation) must not call such an object
|
||||
/// unchanged.
|
||||
pub fn xref_oddities(&self) -> &[(u64, u32)] {
|
||||
&self.xref_oddities
|
||||
}
|
||||
|
||||
/// Whether the cross-reference entry of `num` locates that object itself: its `num G obj`
|
||||
/// header at exactly the recorded offset, or the member with that number at the recorded
|
||||
/// position of its object stream. When it does not, [`Self::get`] still finds the object by
|
||||
/// searching, but another reader may resolve the number differently. Ignores unsaved edits.
|
||||
pub fn xref_entry_is_exact(&self, num: u32) -> bool {
|
||||
match self.xref_entry(num) {
|
||||
None | Some(XrefEntry::Free { .. }) => true,
|
||||
Some(XrefEntry::InFile { offset, .. }) => {
|
||||
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num);
|
||||
// Offsets may count from a header that does not start the file.
|
||||
usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
|
||||
}
|
||||
Some(XrefEntry::InStream { stream, index }) => self
|
||||
.objstm(stream)
|
||||
.is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|m| m.is_some_and(|(n, _)| n == num))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Object `num` has an unsaved edit.
|
||||
pub fn is_edited(&self, num: u32) -> bool {
|
||||
self.overlay.contains_key(&num)
|
||||
}
|
||||
|
||||
pub fn root(&self) -> Option<ObjRef> {
|
||||
self.trailer.reference(b"Root")
|
||||
}
|
||||
|
||||
// ── object access ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10).
|
||||
pub fn get(&self, r: ObjRef) -> Arc<Object> {
|
||||
self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null))
|
||||
}
|
||||
|
||||
/// Follow a reference if `o` is one; otherwise return `o` itself.
|
||||
pub fn resolve(&self, o: &Object) -> Arc<Object> {
|
||||
match o {
|
||||
Object::Ref(r) => self.get(*r),
|
||||
other => Arc::new(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve and return a dictionary (a stream's dictionary counts).
|
||||
pub fn dict(&self, o: &Object) -> Option<Dict> {
|
||||
self.resolve(o).as_dict().cloned()
|
||||
}
|
||||
|
||||
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
|
||||
match self.overlay.get(&num) {
|
||||
Some(Slot::Set(_, o)) => return Ok(o.clone()),
|
||||
Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)),
|
||||
None => {}
|
||||
}
|
||||
if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
|
||||
return Ok(o.clone());
|
||||
}
|
||||
// Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
|
||||
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
|
||||
let _guard =
|
||||
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
|
||||
let obj = self.load(num, 0)?;
|
||||
let obj = Arc::new(obj);
|
||||
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
|
||||
// is cheap, and returned objects outlive eviction.
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
|
||||
self.evict_editor_cache_if_over_budget(num, &obj)?;
|
||||
Ok(obj)
|
||||
}
|
||||
|
||||
/// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB)
|
||||
/// so interactive editing does not thrash, but still capped so touching a large
|
||||
/// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes
|
||||
/// count; file-backed views re-parse for free.
|
||||
///
|
||||
/// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` ×
|
||||
/// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full
|
||||
/// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits.
|
||||
/// The count cap always holds; byte accounting between passes is approximate.
|
||||
/// Clones share these caches, so one clone's eviction drops another's hot set;
|
||||
/// that only costs re-parsing, never correctness. Eviction keeps just the newcomer:
|
||||
/// file-backed views re-parse cheaply and editor working sets stay far below 1024.
|
||||
pub(super) const EDITOR_CACHE_OBJECTS: usize = 1024;
|
||||
|
||||
pub(super) const EDITOR_CACHE_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Object streams decode to megabytes each, so fewer are kept than plain objects.
|
||||
const EDITOR_OBJSTM_OBJECTS: usize = 256;
|
||||
|
||||
const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Newcomers above this force a full accounting pass even below the count cap:
|
||||
/// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the
|
||||
/// byte budget can double.
|
||||
const EDITOR_LARGE_OBJECT: usize = 256 * 1024;
|
||||
|
||||
fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> {
|
||||
// Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer
|
||||
// below the count cap. `len` is copied out first; nothing here holds the lock.
|
||||
let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len();
|
||||
if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT {
|
||||
return Ok(());
|
||||
}
|
||||
// Walk a snapshot off-lock; re-lock only to evict.
|
||||
let live: Vec<(u32, Arc<Object>)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect();
|
||||
let data = &self.data;
|
||||
let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0)));
|
||||
if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
// Re-check under the final lock: another thread may have inserted or evicted since
|
||||
// the snapshot. Keep its newcomers, and skip the eviction if back within budget.
|
||||
let snapshot: std::collections::HashSet<u32> = live.into_iter().map(|(n, _)| n).collect();
|
||||
let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?;
|
||||
if cache.len() <= Self::EDITOR_CACHE_OBJECTS {
|
||||
let data = &self.data;
|
||||
let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0)));
|
||||
if owned <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn evict_editor_streams_if_over_budget(&self, current: u32) -> Result<(), CosError> {
|
||||
// The caller inserts `current` first, so retaining it keeps a live entry.
|
||||
let over = {
|
||||
let streams = self.objstms.lock().map_err(|_| CosError::Poisoned)?;
|
||||
let bytes = streams.values().fold(0usize, |bytes, stream| {
|
||||
bytes
|
||||
.saturating_add(stream.data.capacity())
|
||||
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<Option<(u32, usize)>>()))
|
||||
});
|
||||
bytes > Self::EDITOR_OBJSTM_BYTES || streams.len() > Self::EDITOR_OBJSTM_OBJECTS
|
||||
};
|
||||
if over {
|
||||
self.objstms.lock().map_err(|_| CosError::Poisoned)?.retain(|n, _| *n == current);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Visit every current object without filling this document's editing cache.
|
||||
///
|
||||
/// Full-document searches (for example, standalone timestamps) need to inspect objects
|
||||
/// that may never be used again. Keep their parse caches private and release them in
|
||||
/// batches. Returned objects own their data and remain valid after the next batch.
|
||||
pub fn scan_objects(&self) -> impl Iterator<Item = (ObjRef, Arc<Object>)> + use<> {
|
||||
let numbers = self.object_numbers();
|
||||
let mut reader = self.object_reader();
|
||||
numbers.into_iter().map(move |num| {
|
||||
let reference = ObjRef::new(num, reader.document.generation(num));
|
||||
(reference, reader.get(reference))
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`Self::scan_objects`], preserving read errors for callers that must distinguish
|
||||
/// incomplete discovery from an actual null object. Caches remain private and bounded.
|
||||
pub fn scan_objects_checked(&self) -> impl Iterator<Item = (ObjRef, Result<Arc<Object>, CosError>)> + use<> {
|
||||
self.scan_objects_subset(self.object_numbers())
|
||||
}
|
||||
|
||||
/// Inspect selected current objects with the same bounded cache as a whole-file scan.
|
||||
/// Missing/freed numbers yield `Null`; malformed objects return their read error.
|
||||
pub fn scan_objects_subset(&self, numbers: Vec<u32>) -> impl Iterator<Item = (ObjRef, Result<Arc<Object>, CosError>)> + use<> {
|
||||
let mut reader = self.object_reader();
|
||||
numbers.into_iter().map(move |num| {
|
||||
let reference = ObjRef::new(num, reader.document.generation(num));
|
||||
(reference, reader.try_get(reference))
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect selected objects in the immutable source, ignoring all unsaved edits. This
|
||||
/// allows a consumer to check an edit's dependencies without retaining an old document.
|
||||
pub fn scan_original_objects_subset(&self, numbers: Vec<u32>) -> impl Iterator<Item = (ObjRef, Result<Arc<Object>, CosError>)> + use<> {
|
||||
let mut reader = self.object_reader();
|
||||
reader.document.overlay.clear();
|
||||
numbers.into_iter().map(move |num| {
|
||||
let reference = ObjRef::new(num, reader.document.generation(num));
|
||||
(reference, reader.try_get(reference))
|
||||
})
|
||||
}
|
||||
|
||||
/// Opened with a tighter stream limit than editing uses ([`Document::open_with_stream_limit`]).
|
||||
pub(crate) fn stream_limited(&self) -> bool {
|
||||
self.stream_limit < crate::object::MAX_DECODED
|
||||
}
|
||||
|
||||
pub(crate) fn object_reader(&self) -> ObjectReader {
|
||||
let mut document = self.clone();
|
||||
document.cache = Arc::default();
|
||||
document.objstms = Arc::default();
|
||||
ObjectReader { document, decoded_count: 0 }
|
||||
}
|
||||
|
||||
fn load(&self, num: u32, depth: u32) -> Result<Object, CosError> {
|
||||
if depth > 16 {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() });
|
||||
}
|
||||
match self.entries.lookup(num, &self.data) {
|
||||
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
|
||||
Some(XrefEntry::InFile { offset, .. }) => {
|
||||
let resolve = |r: ObjRef| {
|
||||
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) {
|
||||
return known;
|
||||
}
|
||||
let length = self.try_get(r.num).ok().and_then(|o| o.as_int());
|
||||
LENGTHS.with(|m| m.borrow_mut().insert(r.num, length));
|
||||
length
|
||||
};
|
||||
let off = offset as usize;
|
||||
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) {
|
||||
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
|
||||
_ => None,
|
||||
};
|
||||
// Offsets relative to a shifted header, or simply wrong: try both fixes.
|
||||
at(off)
|
||||
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
|
||||
.or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at))
|
||||
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
|
||||
.ok_or(CosError::MissingObject(num))
|
||||
}
|
||||
Some(XrefEntry::InStream { stream, index }) => {
|
||||
let stm = self.objstm(stream)?;
|
||||
// The member at the entry's position, else wherever the stream lists `num`.
|
||||
let at = usize::try_from(index).ok().and_then(|i| stm.index.get(i)).copied().flatten().filter(|(n, _)| *n == num);
|
||||
let (_, off) = at.or_else(|| stm.index.iter().flatten().find(|(n, _)| *n == num).copied()).ok_or(CosError::MissingObject(num))?;
|
||||
Lexer::new(&stm.data, off).object()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn objstm(&self, num: u32) -> Result<Arc<ObjStm>, CosError> {
|
||||
if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
|
||||
return Ok(s.clone());
|
||||
}
|
||||
// A file entry that cannot place the stream (one that says it is itself compressed
|
||||
// inside another stream, as in #2367, or points nowhere) is tried by the stream's own
|
||||
// header, as a plain object with a wrong offset is. An edited or freed stream, in the
|
||||
// overlay or in a saved revision's table, is what that says it is: the file's copy
|
||||
// must not come back.
|
||||
let loaded = match self.try_get(num) {
|
||||
Ok(o) if matches!(*o, Object::Stream(_)) => o,
|
||||
_ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => {
|
||||
return Err(CosError::MissingObject(num));
|
||||
}
|
||||
_ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
|
||||
};
|
||||
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
|
||||
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
|
||||
let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok();
|
||||
let data = s.decoded_within(self.stream_limit)?;
|
||||
let mut lx = Lexer::new(&data, 0);
|
||||
let mut index = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break };
|
||||
index.push(objstm_member(onum, off, first, data.len()));
|
||||
}
|
||||
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
|
||||
// Same bound as the object cache: decoded object streams can be megabytes each.
|
||||
// Insert first so the retained `num` is actually in the map.
|
||||
self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone());
|
||||
self.evict_editor_streams_if_over_budget(num)?;
|
||||
Ok(stm)
|
||||
}
|
||||
|
||||
/// Last-resort lookup: scan the file for `num G obj`.
|
||||
fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> {
|
||||
let needle = format!("{num} ");
|
||||
let data = &self.data;
|
||||
let mut found = None;
|
||||
let mut i = 0;
|
||||
while let Some(p) = find(data, needle.as_bytes(), i, data.len()) {
|
||||
i = p + 1;
|
||||
if p > 0 && !is_whitespace(data[p - 1]) {
|
||||
continue;
|
||||
}
|
||||
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams)
|
||||
&& id.num == num
|
||||
{
|
||||
found = Some((id, o)); // keep the last (newest) definition
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
// ── editing ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stream data still pointing into another document's file would keep that whole file
|
||||
/// alive for as long as this edit exists: give it a buffer of its own (just its bytes).
|
||||
fn adopt(&self, obj: Object) -> Object {
|
||||
match obj {
|
||||
Object::Stream(mut s) if s.raw.borrows_other_than(&self.data) => {
|
||||
s.raw = s.raw.detached();
|
||||
Object::Stream(s)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace an object (keeping its generation).
|
||||
pub fn set(&mut self, r: ObjRef, obj: impl Into<Object>) {
|
||||
let obj = self.adopt(obj.into());
|
||||
self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj)));
|
||||
if r.num >= self.next_num {
|
||||
self.next_num = r.num.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new indirect object and return its reference.
|
||||
pub fn add(&mut self, obj: impl Into<Object>) -> ObjRef {
|
||||
let r = ObjRef::new(self.allocate_num(), 0);
|
||||
let obj = self.adopt(obj.into());
|
||||
self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj)));
|
||||
r
|
||||
}
|
||||
|
||||
/// Find a never-used number, retaining the high-water mark for incremental /Size.
|
||||
/// Exhausted hints fall back to gaps without replacing source or overlay objects.
|
||||
fn allocate_num(&mut self) -> u32 {
|
||||
let mut num = if self.next_num == u32::MAX { 1 } else { self.next_num.max(1) };
|
||||
while self.entries.lookup(num, &self.data).is_some() || self.overlay.contains_key(&num) {
|
||||
num = if num == u32::MAX - 1 { 1 } else { num + 1 };
|
||||
}
|
||||
self.next_num = self.next_num.max(num.saturating_add(1));
|
||||
num
|
||||
}
|
||||
|
||||
/// Delete an object (its number becomes free with the next generation).
|
||||
pub fn free(&mut self, r: ObjRef) {
|
||||
self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
|
||||
}
|
||||
|
||||
/// Free what was added since the file was read and nothing refers to any more: the objects
|
||||
/// [`SaveOptions::drop_unlinked`](crate::SaveOptions::drop_unlinked) leaves out of an
|
||||
/// incremental save, freed as it writes them (the generation stays). For a writer about to
|
||||
/// add an object that nothing refers to either, which that option would leave out too.
|
||||
pub fn free_unlinked_additions(&mut self) {
|
||||
for num in crate::writer::unlinked_additions(self) {
|
||||
let generation = self.generation(num);
|
||||
self.overlay.insert(num, Slot::Freed(generation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `current`'s object graph as unsaved edits of this document.
|
||||
///
|
||||
/// Used when the working snapshot was restored from an earlier revision and the next
|
||||
/// incremental save must still append to this file (the last saved revision). Objects
|
||||
/// that already match are left untouched. Reads go through private readers so neither
|
||||
/// document's editing cache is filled.
|
||||
pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> {
|
||||
let saved_nums = self.object_numbers();
|
||||
let current_nums = current.object_numbers();
|
||||
let mut freed = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
{
|
||||
let mut saved_reader = self.object_reader();
|
||||
let mut current_reader = current.object_reader();
|
||||
for &num in &saved_nums {
|
||||
if current_nums.binary_search(&num).is_err() {
|
||||
freed.push(num);
|
||||
}
|
||||
}
|
||||
for &num in ¤t_nums {
|
||||
let generation = current.generation(num);
|
||||
let same = saved_nums.binary_search(&num).is_ok()
|
||||
&& self.generation(num) == generation
|
||||
&& saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?;
|
||||
if !same {
|
||||
changed.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
for num in freed {
|
||||
let generation = self.generation(num);
|
||||
self.free(ObjRef::new(num, generation));
|
||||
}
|
||||
let mut current_reader = current.object_reader();
|
||||
for num in changed {
|
||||
let generation = current.generation(num);
|
||||
let object = current_reader.try_get(ObjRef::new(num, 0))?;
|
||||
self.set(ObjRef::new(num, generation), (*object).clone());
|
||||
}
|
||||
*self.trailer_mut() = current.trailer().clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Edit a dictionary (or stream dictionary) object in place.
|
||||
pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> {
|
||||
let mut obj = (*self.get(r)).clone();
|
||||
let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?;
|
||||
f(d);
|
||||
self.set(r, obj);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current generation of an object number.
|
||||
pub fn generation(&self, num: u32) -> u16 {
|
||||
match self.overlay.get(&num) {
|
||||
Some(Slot::Set(g, _)) => *g,
|
||||
Some(Slot::Freed(g)) => *g,
|
||||
None => match self.entries.lookup(num, &self.data) {
|
||||
Some(XrefEntry::InFile { generation, .. }) => generation,
|
||||
Some(XrefEntry::Free { next_generation }) => next_generation,
|
||||
_ => 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn overlay_entries(&self) -> impl Iterator<Item = (u32, u16, Option<Arc<Object>>)> + '_ {
|
||||
self.overlay.iter().map(|(n, s)| match s {
|
||||
Slot::Set(g, o) => (*n, *g, Some(o.clone())),
|
||||
Slot::Freed(g) => (*n, *g, None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_num(&self) -> u32 {
|
||||
self.next_num
|
||||
}
|
||||
|
||||
/// All object numbers known (file and overlay), excluding freed ones.
|
||||
pub fn object_numbers(&self) -> Vec<u32> {
|
||||
let source_numbers = self.entries.object_numbers(&self.data);
|
||||
let mut numbers = Vec::with_capacity(source_numbers.len().max(self.overlay.len()));
|
||||
let mut source = source_numbers.into_iter().peekable();
|
||||
for (&num, slot) in &self.overlay {
|
||||
while source.peek().is_some_and(|n| *n < num) {
|
||||
if let Some(n) = source.next() {
|
||||
numbers.push(n);
|
||||
}
|
||||
}
|
||||
if source.peek().is_some_and(|n| *n == num) {
|
||||
source.next();
|
||||
}
|
||||
if matches!(slot, Slot::Set(..)) {
|
||||
numbers.push(num);
|
||||
}
|
||||
}
|
||||
numbers.extend(source);
|
||||
numbers
|
||||
}
|
||||
|
||||
/// Rebase this document on bytes written by the writer (after a save): edits become part of
|
||||
/// the file and the overlay is cleared, so the next incremental save appends only new edits.
|
||||
pub fn reopen_after_save(&self, bytes: Arc<Vec<u8>>) -> Result<Self, CosError> {
|
||||
Document::open(bytes)
|
||||
}
|
||||
|
||||
// ── cross-reference parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn root_is_catalog(&self) -> bool {
|
||||
let Some(r) = self.root() else { return false };
|
||||
matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
use super::reader::{find, generated_id};
|
||||
use super::state::Credentials;
|
||||
use super::xref::XrefIndex;
|
||||
use super::*;
|
||||
|
||||
impl Document {
|
||||
/// A new document with an empty page tree (for split / extract / combine). It has no
|
||||
/// original bytes, so saving always writes a full file.
|
||||
pub fn new_empty() -> Self {
|
||||
let mut doc = Document {
|
||||
data: Arc::new(Vec::new()),
|
||||
endstreams: Arc::default(),
|
||||
source_identity: Arc::new(()),
|
||||
entries: Arc::new(XrefIndex::empty()),
|
||||
trailer: Dict::new(),
|
||||
revisions: Arc::new(Vec::new()),
|
||||
xref_oddities: Arc::default(),
|
||||
located: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
stream_limit: crate::object::MAX_DECODED,
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
header_offset: 0,
|
||||
version: "1.7".into(),
|
||||
security: None,
|
||||
encrypt_num: None,
|
||||
encryption_changed: false,
|
||||
full_save: false,
|
||||
out_security: None,
|
||||
out_encrypt_num: None,
|
||||
};
|
||||
let mut pages = Dict::new();
|
||||
pages.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
pages.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
pages.set(b"Count".to_vec(), Object::Int(0));
|
||||
let pages = doc.add(pages);
|
||||
let mut catalog = Dict::new();
|
||||
catalog.set(b"Type".to_vec(), Object::name("Catalog"));
|
||||
catalog.set(b"Pages".to_vec(), Object::Ref(pages));
|
||||
let root = doc.add(catalog);
|
||||
doc.trailer.set(b"Root".to_vec(), Object::Ref(root));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`.
|
||||
/// Encrypted documents open only if their user password is empty; see `open_with_password`.
|
||||
pub fn open(data: Arc<Vec<u8>>) -> Result<Self, CosError> {
|
||||
Self::open_with_password(data, None)
|
||||
}
|
||||
|
||||
/// Parse a document, authenticating encrypted ones with `password` (owner or user; `None`
|
||||
/// tries the empty password). Fails with `NeedsPassword` / `WrongPassword` as appropriate.
|
||||
pub fn open_with_password(data: Arc<Vec<u8>>, password: Option<&str>) -> Result<Self, CosError> {
|
||||
Self::open_with_stream_limit(data, password, crate::object::MAX_DECODED)
|
||||
}
|
||||
|
||||
/// Open with a tighter decompression bound for object and cross-reference streams.
|
||||
/// Inspection can use this without changing the editing reader's default limit. Such a
|
||||
/// document is read-only: objects in a larger object stream read as missing, so the
|
||||
/// writers refuse to save it ([`CosError::ReadOnlyLimit`]).
|
||||
pub fn open_with_stream_limit(data: Arc<Vec<u8>>, password: Option<&str>, stream_limit: usize) -> Result<Self, CosError> {
|
||||
Self::open_as(data, Credentials::Password(password), stream_limit)
|
||||
}
|
||||
|
||||
/// Open `data`, an earlier revision of this file (the bytes a signature covers) or another
|
||||
/// save of it, with this document's credentials: an encrypted one must have the same
|
||||
/// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no
|
||||
/// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data
|
||||
/// encrypted differently fails with `NeedsPassword`.
|
||||
pub fn open_with_same_credentials(&self, data: Arc<Vec<u8>>) -> Result<Self, CosError> {
|
||||
let credentials = match &self.security {
|
||||
Some(handler) => Credentials::Handler { handler, id0: self.first_id() },
|
||||
None => Credentials::Password(None),
|
||||
};
|
||||
Self::open_as(data, credentials, self.stream_limit)
|
||||
}
|
||||
|
||||
fn open_as(data: Arc<Vec<u8>>, credentials: Credentials<'_>, stream_limit: usize) -> Result<Self, CosError> {
|
||||
// Viewers accept files whose header is missing or damaged as long as the body looks
|
||||
// like PDF; so do we (a note goes to the repair log).
|
||||
let header = find(&data, b"%PDF-", 0, 1024);
|
||||
let headerless = header.is_none();
|
||||
if headerless && find(&data, b" obj", 0, 4096).is_none() {
|
||||
return Err(CosError::NotPdf);
|
||||
}
|
||||
let header_offset = header.unwrap_or(0);
|
||||
let version = match header {
|
||||
Some(h) => String::from_utf8_lossy(&data[(h + 5).min(data.len())..(h + 8).min(data.len())]).trim().to_string(),
|
||||
None => "1.4".to_string(),
|
||||
};
|
||||
let mut doc = Document {
|
||||
data,
|
||||
endstreams: Arc::default(),
|
||||
source_identity: Arc::new(()),
|
||||
entries: Arc::new(XrefIndex::empty()),
|
||||
trailer: Dict::new(),
|
||||
revisions: Arc::new(Vec::new()),
|
||||
xref_oddities: Arc::default(),
|
||||
located: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
header_offset,
|
||||
version,
|
||||
security: None,
|
||||
encrypt_num: None,
|
||||
encryption_changed: false,
|
||||
full_save: false,
|
||||
out_security: None,
|
||||
out_encrypt_num: None,
|
||||
};
|
||||
let mut log = Vec::new();
|
||||
if headerless {
|
||||
log.push("the %PDF- header is missing; reading the file as PDF 1.4".into());
|
||||
}
|
||||
let parsed = doc.read_xref_chain(&mut log);
|
||||
let mut authenticated = false;
|
||||
let ok = match parsed {
|
||||
Ok((entries, trailer, revisions, oddities)) => {
|
||||
doc.entries = Arc::new(entries);
|
||||
doc.trailer = trailer;
|
||||
doc.revisions = Arc::new(revisions);
|
||||
doc.xref_oddities = Arc::new(oddities);
|
||||
doc.fill_xref_gaps(&mut log);
|
||||
// Authenticate before checking the catalog: it may sit in an encrypted object
|
||||
// stream, which reads as garbage until the key is known.
|
||||
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
|
||||
doc.authenticate(&enc, &credentials)?;
|
||||
authenticated = true;
|
||||
}
|
||||
doc.root_is_catalog()
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
|
||||
false
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
if !doc.entries.is_empty() {
|
||||
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
|
||||
}
|
||||
doc.reconstruct(&mut log)?;
|
||||
}
|
||||
doc.repair_log = Arc::new(log);
|
||||
// Reconstruction may have found a different trailer: (re)authenticate against it.
|
||||
if (!ok || !authenticated)
|
||||
&& let Some(enc) = doc.trailer.get(b"Encrypt").cloned()
|
||||
{
|
||||
doc.authenticate(&enc, &credentials)?;
|
||||
}
|
||||
if Arc::make_mut(&mut doc.entries).remove_exhausted() {
|
||||
Arc::make_mut(&mut doc.repair_log).push("skipped exhausted xref object number 4294967295".into());
|
||||
}
|
||||
let max = doc.entries.max_num();
|
||||
let size = u32::try_from(doc.trailer.int(b"Size").unwrap_or(0).max(0)).unwrap_or(u32::MAX);
|
||||
if size == u32::MAX {
|
||||
Arc::make_mut(&mut doc.repair_log).push("exhausted trailer /Size; new objects use unused object numbers".into());
|
||||
}
|
||||
doc.next_num = max.saturating_add(1).max(size).max(1);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
|
||||
// Authentication can change how the same input is decoded.
|
||||
self.source_identity = Arc::new(());
|
||||
let (num, dict) = match enc {
|
||||
Object::Ref(r) => (Some(r.num), self.get(*r).as_dict().cloned()),
|
||||
Object::Dict(d) => (None, Some(d.clone())),
|
||||
_ => (None, None),
|
||||
};
|
||||
let Some(dict) = dict else {
|
||||
// A dangling /Encrypt: viewers treat the file as unencrypted, and so do we.
|
||||
let mut log = self.repair_log.as_ref().clone();
|
||||
log.push("the trailer names an /Encrypt dictionary that does not exist; reading the file as unencrypted".into());
|
||||
self.repair_log = Arc::new(log);
|
||||
self.trailer.remove(b"Encrypt");
|
||||
return Ok(());
|
||||
};
|
||||
let params = crate::security::encrypt_dict(self, &dict);
|
||||
let id0 = self.first_id();
|
||||
let handler = match credentials {
|
||||
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?),
|
||||
// The same encryption as the document whose key this is: that key opens it.
|
||||
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
|
||||
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
|
||||
};
|
||||
self.security = Some(handler);
|
||||
self.encrypt_num = num;
|
||||
// Objects read while locating the catalog were read before decryption was possible.
|
||||
self.cache = Arc::default();
|
||||
self.objstms = Arc::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first file identifier, part of the encryption key (empty when there is none).
|
||||
fn first_id(&self) -> Vec<u8> {
|
||||
match self.trailer.get(b"ID") {
|
||||
Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the restrictions of a document opened with its user password by entering the
|
||||
/// owner (permissions) password (§7.6.4). Fails with `WrongPassword` for any other
|
||||
/// password. Does nothing when the document is unencrypted or already opened as owner.
|
||||
/// Both passwords unlock the same file key, so nothing read so far is decoded again.
|
||||
pub fn unlock(&mut self, password: &str) -> Result<(), CosError> {
|
||||
let Some(current) = self.security.as_deref() else { return Ok(()) };
|
||||
if current.auth() == pdfcraft_crypt::Auth::Owner {
|
||||
return Ok(());
|
||||
}
|
||||
let owner = current.authenticate_owner(&self.first_id(), password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?;
|
||||
self.security = Some(Arc::new(owner));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The security handler, when the document is encrypted.
|
||||
pub fn security(&self) -> Option<&pdfcraft_crypt::SecurityHandler> {
|
||||
self.security.as_deref()
|
||||
}
|
||||
|
||||
/// What the opening password allows (`None` for unencrypted documents: everything).
|
||||
pub fn permissions(&self) -> Option<pdfcraft_crypt::Permissions> {
|
||||
self.security().map(|s| s.permissions())
|
||||
}
|
||||
|
||||
/// Make the next save a full rewrite: earlier revisions (which still hold what an edit
|
||||
/// removed) must not stay in the file. Redaction requires this.
|
||||
pub fn require_full_save(&mut self) {
|
||||
self.full_save = true;
|
||||
}
|
||||
|
||||
/// The next save must rewrite the whole file (see [`Document::require_full_save`]).
|
||||
pub fn full_save_required(&self) -> bool {
|
||||
self.full_save
|
||||
}
|
||||
|
||||
/// Security was set or removed since the document was opened (the next save applies it).
|
||||
pub fn encryption_changed(&self) -> bool {
|
||||
self.encryption_changed
|
||||
}
|
||||
|
||||
/// The handler used to write: the new one after `set_encryption` / `remove_encryption`,
|
||||
/// otherwise the one the document was opened with.
|
||||
pub(crate) fn output_security(&self) -> (Option<&pdfcraft_crypt::SecurityHandler>, Option<u32>) {
|
||||
if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) }
|
||||
}
|
||||
|
||||
/// The security the next save writes: protection applied with `set_encryption`, none after
|
||||
/// `remove_encryption`, otherwise the security the document was opened with.
|
||||
pub fn output_handler(&self) -> Option<&pdfcraft_crypt::SecurityHandler> {
|
||||
self.output_security().0
|
||||
}
|
||||
|
||||
/// Protect the document with a password (§7.6.4). Takes effect on the next save, which is
|
||||
/// always a full rewrite. Returns the handler (authenticated as owner).
|
||||
pub fn set_encryption(&mut self, params: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> {
|
||||
// The file identifier is part of the key; make sure it exists and keep it.
|
||||
let id0 = match self.trailer.get(b"ID") {
|
||||
Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(),
|
||||
_ => {
|
||||
let mut id = generated_id(¶ms.seed);
|
||||
id.truncate(16);
|
||||
let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true });
|
||||
self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
|
||||
id
|
||||
}
|
||||
};
|
||||
let h = pdfcraft_crypt::create(params, &id0).map_err(|e| CosError::Security(e.to_string()))?;
|
||||
let d = h.dict();
|
||||
let s = |b: &[u8]| Object::String(crate::PdfString { bytes: b.to_vec(), hex: true });
|
||||
let mut e = Dict::new();
|
||||
e.set(b"Filter".to_vec(), Object::name("Standard"));
|
||||
e.set(b"V".to_vec(), Object::Int(d.v));
|
||||
e.set(b"R".to_vec(), Object::Int(d.r));
|
||||
e.set(b"Length".to_vec(), Object::Int(d.length_bits));
|
||||
e.set(b"O".to_vec(), s(&d.o));
|
||||
e.set(b"U".to_vec(), s(&d.u));
|
||||
e.set(b"P".to_vec(), Object::Int(i64::from(d.p)));
|
||||
if d.v >= 4 {
|
||||
let mut cf = Dict::new();
|
||||
let mut std_cf = Dict::new();
|
||||
std_cf.set(b"Type".to_vec(), Object::name("CryptFilter"));
|
||||
let (cfm, len) = if d.v >= 5 { ("AESV3", 32) } else { ("AESV2", 16) };
|
||||
std_cf.set(b"CFM".to_vec(), Object::name(cfm));
|
||||
std_cf.set(b"Length".to_vec(), Object::Int(len));
|
||||
std_cf.set(b"AuthEvent".to_vec(), Object::name("DocOpen"));
|
||||
cf.set(b"StdCF".to_vec(), Object::Dict(std_cf));
|
||||
e.set(b"CF".to_vec(), Object::Dict(cf));
|
||||
e.set(b"StmF".to_vec(), Object::name("StdCF"));
|
||||
e.set(b"StrF".to_vec(), Object::name("StdCF"));
|
||||
if !d.encrypt_metadata {
|
||||
e.set(b"EncryptMetadata".to_vec(), Object::Bool(false));
|
||||
}
|
||||
}
|
||||
if d.v >= 5 {
|
||||
e.set(b"OE".to_vec(), s(&d.oe));
|
||||
e.set(b"UE".to_vec(), s(&d.ue));
|
||||
e.set(b"Perms".to_vec(), s(&d.perms));
|
||||
}
|
||||
if let Some(Object::Ref(old)) = self.trailer.get(b"Encrypt").cloned() {
|
||||
self.free(old);
|
||||
}
|
||||
let r = self.add(e);
|
||||
self.trailer.set(b"Encrypt".to_vec(), Object::Ref(r));
|
||||
// Objects are still read with the original handler; saves use the new one.
|
||||
self.out_security = Some(Arc::new(h));
|
||||
self.out_encrypt_num = Some(r.num);
|
||||
self.encryption_changed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove password protection (requires the document to be open — any authenticated
|
||||
/// password; callers should require the owner password, §7.6.4). Applied by a full save.
|
||||
pub fn remove_encryption(&mut self) {
|
||||
if let Some(Object::Ref(r)) = self.trailer.get(b"Encrypt").cloned() {
|
||||
self.free(r);
|
||||
}
|
||||
self.trailer.remove(b"Encrypt");
|
||||
self.out_security = None;
|
||||
self.out_encrypt_num = None;
|
||||
self.encryption_changed = true;
|
||||
}
|
||||
|
||||
/// Decrypt a freshly parsed indirect object if the document is encrypted.
|
||||
pub(super) fn decrypted(&self, id: ObjRef, o: Object) -> Object {
|
||||
match &self.security {
|
||||
Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt),
|
||||
_ => o,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
#[cfg(test)]
|
||||
use super::state::SECTION_READS;
|
||||
use super::xref::{
|
||||
MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
impl Document {
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(XrefIndex, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
|
||||
let mut compact_log = Vec::new();
|
||||
if let Ok(Some((index, trailer, revisions))) = self.read_compact_xref_chain(&mut compact_log) {
|
||||
let size = trailer.int(b"Size").unwrap_or(0).max(0);
|
||||
if size > 0 && size <= i64::from(u32::MAX) && u64::from(index.max_num()) < size as u64 {
|
||||
log.extend(compact_log);
|
||||
// The compact reader declines any section with set-aside entries, so there are none here.
|
||||
return Ok((index, trailer, revisions, Vec::new()));
|
||||
}
|
||||
}
|
||||
let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?;
|
||||
Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities))
|
||||
}
|
||||
|
||||
fn read_compact_xref_chain(&self, log: &mut Vec<String>) -> Result<Option<(XrefIndex, Dict, Vec<Revision>)>, CosError> {
|
||||
let data = &self.data;
|
||||
let tail_start = data.len().saturating_sub(4096);
|
||||
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
|
||||
let mut lx = Lexer::new(data, sx + 9);
|
||||
let first = match lx.object() {
|
||||
Ok(Object::Int(n)) if n >= 0 => n as u64,
|
||||
_ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }),
|
||||
};
|
||||
let mut sections = Vec::new();
|
||||
let mut retained_rows = 0usize;
|
||||
let mut retained_bytes = 0usize;
|
||||
let mut trailer: Option<Dict> = None;
|
||||
let mut revisions = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
let mut next = Some(first);
|
||||
while let Some(off) = next.take() {
|
||||
if !visited.insert(off) || visited.len() > 10_000 {
|
||||
log.push(format!("cross-reference chain loops at offset {off}; stopped"));
|
||||
break;
|
||||
}
|
||||
let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) {
|
||||
Ok(Some(v)) => (v, false),
|
||||
Ok(None) => return Ok(None),
|
||||
Err(e) if self.header_offset > 0 => {
|
||||
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
|
||||
match self.read_compact_section(off as usize + self.header_offset)? {
|
||||
Some(v) => (v, true),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
// An unreadable older section loses every object only it locates: the
|
||||
// materialized reader logs it and the file is rebuilt from object headers (#1656).
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
revisions.push(Revision { xref_offset: off, is_stream, header_relative });
|
||||
if primary.renumbered {
|
||||
log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
|
||||
}
|
||||
let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) };
|
||||
let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else {
|
||||
return Ok(None);
|
||||
};
|
||||
retained_rows = rows;
|
||||
retained_bytes = bytes;
|
||||
let hybrid = if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
|
||||
match self.read_compact_section(x as usize) {
|
||||
Ok(Some((section, _, _))) => {
|
||||
let Some(rows) = retained_rows.checked_add(section.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(bytes) = retained_bytes.checked_add(section.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
retained_rows = rows;
|
||||
retained_bytes = bytes;
|
||||
Some(section)
|
||||
}
|
||||
Ok(None) => return Ok(None),
|
||||
Err(e) => {
|
||||
log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(hybrid) = &hybrid {
|
||||
// A hybrid entry that disagrees with the table is set aside by the materialized reader.
|
||||
let mut numbers = Vec::new();
|
||||
hybrid.extend_numbers(&mut numbers);
|
||||
for num in numbers {
|
||||
let Some(entry) = hybrid.lookup(num, &self.data) else { continue };
|
||||
if primary.lookup(num, &self.data).is_some_and(|old| !matches!(old, XrefEntry::Free { .. }) && old != entry) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
sections.push(XrefRevisionSection { primary, hybrid });
|
||||
next = prev_offset(data, §_trailer, off, |num| sections.iter().find_map(|s| s.lookup(num, data)), log);
|
||||
if trailer.is_none() {
|
||||
trailer = Some(sect_trailer);
|
||||
}
|
||||
}
|
||||
revisions.reverse();
|
||||
let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?;
|
||||
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
|
||||
trailer.remove(k);
|
||||
}
|
||||
let mut max_num = 0;
|
||||
let mut has_entries = false;
|
||||
for revision in §ions {
|
||||
max_num = max_num.max(revision.primary.max_num);
|
||||
has_entries |= revision.primary.has_entries;
|
||||
if let Some(hybrid) = &revision.hybrid {
|
||||
max_num = max_num.max(hybrid.max_num);
|
||||
has_entries |= hybrid.has_entries;
|
||||
}
|
||||
}
|
||||
Ok(Some((XrefIndex::Compact { revisions: sections, max_num, has_entries }, trailer, revisions)))
|
||||
}
|
||||
|
||||
fn read_compact_section(&self, off: usize) -> Result<Option<(XrefSection, Dict, bool)>, CosError> {
|
||||
let data = &self.data;
|
||||
let mut lx = Lexer::new(data, off);
|
||||
if lx.eat_keyword(b"xref") {
|
||||
let mut ranges = Vec::new();
|
||||
let mut layout_ok = true;
|
||||
let mut max_num = 0;
|
||||
let mut has_entries = false;
|
||||
let mut previous_end = None;
|
||||
let mut row_count = 0usize;
|
||||
let mut renumbered = false;
|
||||
loop {
|
||||
lx.skip_ws();
|
||||
if lx.eat_keyword(b"trailer") {
|
||||
let t = lx.object()?;
|
||||
let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?;
|
||||
if !layout_ok {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false)));
|
||||
}
|
||||
let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
|
||||
return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
|
||||
};
|
||||
if !valid_xref_range(start, count) || count > 10_000_000 {
|
||||
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
|
||||
}
|
||||
let shifted = subsection_start(data, self.header_offset, lx.pos, start, count);
|
||||
renumbered |= shifted != start;
|
||||
let start = shifted;
|
||||
let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) };
|
||||
if next_rows > MAX_COMPACT_XREF_ROWS {
|
||||
return Ok(None);
|
||||
}
|
||||
row_count = next_rows;
|
||||
let mut range_offset = None;
|
||||
let mut stride = None;
|
||||
let mut previous_record = None;
|
||||
for i in 0..count {
|
||||
lx.skip_ws();
|
||||
let record = lx.pos;
|
||||
if range_offset.is_none() {
|
||||
range_offset = Some(record);
|
||||
}
|
||||
if let Some(previous) = previous_record {
|
||||
let Some(delta) = record.checked_sub(previous) else {
|
||||
layout_ok = false;
|
||||
continue;
|
||||
};
|
||||
if stride.is_some_and(|known| known != delta) {
|
||||
layout_ok = false;
|
||||
}
|
||||
stride.get_or_insert(delta);
|
||||
}
|
||||
previous_record = Some(record);
|
||||
let o = lx.token();
|
||||
lx.skip_ws();
|
||||
let _generation = lx.token();
|
||||
lx.skip_ws();
|
||||
let kind = lx.token();
|
||||
let num = (start + i) as u32;
|
||||
let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::<u64>().ok());
|
||||
match (kind, offset) {
|
||||
(b"n", Some(offset)) if offset != 0 => {
|
||||
has_entries = true;
|
||||
max_num = max_num.max(num);
|
||||
}
|
||||
(b"n", Some(_)) => {
|
||||
// Set aside by the materialized reader, which records it.
|
||||
if num != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
(b"f", _) => {
|
||||
has_entries = true;
|
||||
max_num = max_num.max(num);
|
||||
}
|
||||
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
let range_start = start as u32;
|
||||
let range_count = count as u32;
|
||||
let end = u64::from(range_start) + u64::from(range_count);
|
||||
if previous_end.is_some_and(|previous| u64::from(range_start) < previous) {
|
||||
layout_ok = false;
|
||||
}
|
||||
previous_end = Some(end);
|
||||
ranges.push(XrefRange {
|
||||
start: range_start,
|
||||
count: range_count,
|
||||
offset: range_offset.unwrap_or(lx.pos),
|
||||
stride: stride.unwrap_or(1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, obj) = parse_indirect(data, off, &|_| None)?;
|
||||
let Object::Stream(stream) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) };
|
||||
if stream.dict.name(b"Type") != Some(b"XRef") {
|
||||
return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() });
|
||||
}
|
||||
let w: Vec<usize> = stream
|
||||
.dict
|
||||
.get(b"W")
|
||||
.and_then(Object::as_array)
|
||||
.map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect())
|
||||
.unwrap_or_default();
|
||||
if w.len() < 3 {
|
||||
return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() });
|
||||
}
|
||||
let widths = [w[0], w[1], w[2]];
|
||||
let row = widths.iter().sum::<usize>();
|
||||
if row == 0 {
|
||||
return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() });
|
||||
}
|
||||
let size = stream.dict.int(b"Size").unwrap_or(0).max(0);
|
||||
let index: Vec<i64> = match stream.dict.get(b"Index").and_then(Object::as_array) {
|
||||
Some(a) => a.iter().filter_map(Object::as_int).collect(),
|
||||
None => vec![0, size],
|
||||
};
|
||||
let mut requested_rows = 0usize;
|
||||
let mut previous_end = None;
|
||||
for pair in index.chunks(2) {
|
||||
let [start, count] = pair else { break };
|
||||
if !valid_xref_range(*start, *count) {
|
||||
return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() });
|
||||
}
|
||||
let Ok(count) = usize::try_from(*count) else { return Ok(None) };
|
||||
let Some(next) = requested_rows.checked_add(count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) };
|
||||
if count > 0 {
|
||||
let end = (*start as u64).saturating_add(count as u64);
|
||||
if previous_end.is_some_and(|previous| (*start as u64) < previous) {
|
||||
return Ok(None);
|
||||
}
|
||||
previous_end = Some(end);
|
||||
}
|
||||
requested_rows = next;
|
||||
}
|
||||
if requested_rows.checked_mul(row).is_none_or(|bytes| bytes > MAX_COMPACT_XREF_BYTES) {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = stream.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit).min(MAX_COMPACT_XREF_BYTES))?;
|
||||
if raw.capacity() > MAX_COMPACT_XREF_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
let mut row_start = 0usize;
|
||||
let available_rows = raw.len() / row;
|
||||
for pair in index.chunks(2) {
|
||||
let [start, count] = pair else { break };
|
||||
let range_start = *start as u32;
|
||||
let Ok(requested) = usize::try_from(*count) else { return Ok(None) };
|
||||
let range_count = requested.min(available_rows.saturating_sub(row_start));
|
||||
if range_count > 0 {
|
||||
let Ok(range_count_u32) = u32::try_from(range_count) else { return Ok(None) };
|
||||
let Some(byte_offset) = row_start.checked_mul(row) else { return Ok(None) };
|
||||
ranges.push(XrefRange { start: range_start, count: range_count_u32, offset: byte_offset, stride: row });
|
||||
}
|
||||
let Some(next) = row_start.checked_add(range_count) else { return Ok(None) };
|
||||
row_start = next;
|
||||
}
|
||||
// Rows the materialized reader sets aside (reserved types, offset 0) are left to it.
|
||||
for range in &ranges {
|
||||
for i in 0..range.count as usize {
|
||||
let at = range.offset + i * row;
|
||||
let Some(r) = raw.get(at..at + row) else { break };
|
||||
let field = |from: usize, width: usize, default: u64| -> u64 {
|
||||
if width == 0 {
|
||||
return default;
|
||||
}
|
||||
r[from..from + width].iter().fold(0u64, |acc, byte| acc << 8 | u64::from(*byte))
|
||||
};
|
||||
let kind = field(0, widths[0], 1);
|
||||
let offset = field(widths[0], widths[1], 0);
|
||||
if kind > 2 || (kind == 1 && offset == 0 && range.start + i as u32 != 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0);
|
||||
let row_count = row_start;
|
||||
let section = XrefSection {
|
||||
data: XrefSectionData::Stream { bytes: Arc::new(raw), widths },
|
||||
ranges,
|
||||
row_count,
|
||||
max_num,
|
||||
has_entries: false,
|
||||
renumbered: false,
|
||||
};
|
||||
let has_entries = section.has_any_entry();
|
||||
let section = XrefSection { has_entries, ..section };
|
||||
Ok(Some((section, stream.dict.clone(), true)))
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn read_xref_chain_materialized(
|
||||
&self,
|
||||
log: &mut Vec<String>,
|
||||
) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
|
||||
let data = &self.data;
|
||||
let tail_start = data.len().saturating_sub(4096);
|
||||
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
|
||||
let mut lx = Lexer::new(data, sx + 9);
|
||||
let first = match lx.object() {
|
||||
Ok(Object::Int(n)) if n >= 0 => n as u64,
|
||||
_ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }),
|
||||
};
|
||||
let mut entries = BTreeMap::new();
|
||||
let mut trailer: Option<Dict> = None;
|
||||
let mut revisions = Vec::new();
|
||||
let mut oddities = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
// Hybrid streams already read, by offset: their entries, or `None` when unreadable. A newer
|
||||
// section has merged every entry of one into `entries`, so an older section that names it
|
||||
// again only has its own table compared with them, without decoding the stream again.
|
||||
// At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again.
|
||||
let mut hybrids: HashMap<i64, Option<BTreeMap<u32, XrefEntry>>> = HashMap::new();
|
||||
let (mut hybrid_rows, mut noted) = (0usize, HashSet::new());
|
||||
let mut next = Some(first);
|
||||
while let Some(off) = next.take() {
|
||||
if !visited.insert(off) || visited.len() > 10_000 {
|
||||
log.push(format!("cross-reference chain loops at offset {off}; stopped"));
|
||||
break;
|
||||
}
|
||||
// This section's own entries; older sections only fill what newer ones left out.
|
||||
let mut sect = BTreeMap::new();
|
||||
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
|
||||
let mut odd = Vec::new();
|
||||
let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) {
|
||||
Ok(v) => (v, false),
|
||||
Err(e) if self.header_offset > 0 => {
|
||||
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
|
||||
sect.clear();
|
||||
odd.clear();
|
||||
(self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true)
|
||||
}
|
||||
Err(e) => {
|
||||
// An unreadable older section loses every object only it locates (the page
|
||||
// tree, often): rebuild from object headers, as readers do (#1656).
|
||||
if !revisions.is_empty() {
|
||||
log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
revisions.push(Revision { xref_offset: off, is_stream, header_relative });
|
||||
// Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the
|
||||
// free entries it gives objects only a cross-reference stream can locate.
|
||||
let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0);
|
||||
if let Some((x, read)) = hybrid.and_then(|x| hybrids.get(&x).map(|read| (x, read))) {
|
||||
if noted.insert(x) {
|
||||
log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once"));
|
||||
}
|
||||
if let Some(stm) = read {
|
||||
for (num, old) in § {
|
||||
// The table places the object somewhere other than the stream does.
|
||||
if !matches!(old, XrefEntry::Free { .. }) && stm.get(num).is_some_and(|e| e != old) {
|
||||
odd.push(*num);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(x) = hybrid {
|
||||
let mut stm = BTreeMap::new();
|
||||
let read = match self.read_section(x as usize, &mut stm, &mut odd, log) {
|
||||
Ok(_) => {
|
||||
for (&num, &e) in &stm {
|
||||
match sect.get(&num) {
|
||||
None | Some(XrefEntry::Free { .. }) => {
|
||||
sect.insert(num, e);
|
||||
}
|
||||
// The table already places the object somewhere else.
|
||||
Some(old) if *old != e => odd.push(num),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Some(stm)
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
|
||||
None
|
||||
}
|
||||
};
|
||||
let rows = read.as_ref().map_or(0, BTreeMap::len);
|
||||
if let Some(total) = hybrid_rows.checked_add(rows).filter(|total| *total <= MAX_XREF_OBJECTS) {
|
||||
hybrid_rows = total;
|
||||
hybrids.insert(x, read);
|
||||
}
|
||||
}
|
||||
oddities.extend(odd.into_iter().map(|num| (off, num)));
|
||||
for (num, e) in sect {
|
||||
entries.entry(num).or_insert(e);
|
||||
}
|
||||
next = prev_offset(data, §_trailer, off, |num| entries.get(&num).copied(), log);
|
||||
if trailer.is_none() {
|
||||
trailer = Some(sect_trailer);
|
||||
}
|
||||
}
|
||||
revisions.reverse();
|
||||
let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?;
|
||||
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
|
||||
trailer.remove(k);
|
||||
}
|
||||
Ok((entries, trailer, revisions, oddities))
|
||||
}
|
||||
|
||||
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
|
||||
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
|
||||
fn read_section(
|
||||
&self,
|
||||
off: usize,
|
||||
entries: &mut BTreeMap<u32, XrefEntry>,
|
||||
odd: &mut Vec<u32>,
|
||||
log: &mut Vec<String>,
|
||||
) -> Result<(Dict, bool), CosError> {
|
||||
#[cfg(test)]
|
||||
SECTION_READS.with(|c| c.set(c.get() + 1));
|
||||
let data = &self.data;
|
||||
let mut lx = Lexer::new(data, off);
|
||||
if lx.eat_keyword(b"xref") {
|
||||
loop {
|
||||
lx.skip_ws();
|
||||
if lx.eat_keyword(b"trailer") {
|
||||
let t = lx.object()?;
|
||||
let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?;
|
||||
return Ok((d, false));
|
||||
}
|
||||
let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
|
||||
return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
|
||||
};
|
||||
if !valid_xref_range(start, count) || count > 10_000_000 {
|
||||
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
|
||||
}
|
||||
let shifted = subsection_start(data, self.header_offset, lx.pos, start, count);
|
||||
if shifted != start {
|
||||
log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
|
||||
}
|
||||
let start = shifted;
|
||||
for i in 0..count {
|
||||
lx.skip_ws();
|
||||
let o = lx.token();
|
||||
lx.skip_ws();
|
||||
let g = lx.token();
|
||||
lx.skip_ws();
|
||||
let kind = lx.token();
|
||||
let num = (start + i) as u32;
|
||||
let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::<u64>().ok());
|
||||
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0).min(u16::MAX as u32) as u16;
|
||||
let entry = match (kind, offset) {
|
||||
(b"n", Some(offset)) => XrefEntry::InFile { offset, generation },
|
||||
(b"f", _) => XrefEntry::Free { next_generation: generation },
|
||||
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
|
||||
};
|
||||
// Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0
|
||||
// is never a real object, so its entry cannot mislead anyone.)
|
||||
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
|
||||
if num != 0 {
|
||||
odd.push(num);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *entries.entry(num).or_insert(entry) != entry {
|
||||
odd.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cross-reference stream.
|
||||
let (_, obj) = parse_indirect(data, off, &|_| None)?;
|
||||
let Object::Stream(s) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) };
|
||||
if s.dict.name(b"Type") != Some(b"XRef") {
|
||||
return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() });
|
||||
}
|
||||
let w: Vec<usize> = s
|
||||
.dict
|
||||
.get(b"W")
|
||||
.and_then(Object::as_array)
|
||||
.map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect())
|
||||
.unwrap_or_default();
|
||||
if w.len() < 3 {
|
||||
return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() });
|
||||
}
|
||||
let size = s.dict.int(b"Size").unwrap_or(0).max(0);
|
||||
let index: Vec<i64> = match s.dict.get(b"Index").and_then(Object::as_array) {
|
||||
Some(a) => a.iter().filter_map(Object::as_int).collect(),
|
||||
None => vec![0, size],
|
||||
};
|
||||
let row = w[0] + w[1] + w[2];
|
||||
if row == 0 {
|
||||
return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() });
|
||||
}
|
||||
// One row per object, and a document has at most 8,388,607 indirect objects (the classic
|
||||
// implementation limit): decoding more than that (about 200 MB at the widest rows) is a
|
||||
// decompression bomb, not cross-reference data. The section is then reconstructed.
|
||||
let raw = s.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit))?;
|
||||
let field = |r: &[u8], from: usize, len: usize, default: u64| -> u64 {
|
||||
if len == 0 {
|
||||
return default;
|
||||
}
|
||||
r[from..from + len].iter().fold(0u64, |acc, b| acc << 8 | *b as u64)
|
||||
};
|
||||
let mut rows = raw.chunks_exact(row);
|
||||
for pair in index.chunks(2) {
|
||||
let [start, count] = pair else { break };
|
||||
if !valid_xref_range(*start, *count) {
|
||||
return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() });
|
||||
}
|
||||
for i in 0..*count {
|
||||
let Some(r) = rows.next() else { break };
|
||||
let t = field(r, 0, w[0], 1);
|
||||
let a = field(r, w[0], w[1], 0);
|
||||
let b = field(r, w[0] + w[1], w[2], 0);
|
||||
let num = (*start + i) as u32;
|
||||
let entry = match t {
|
||||
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
|
||||
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
|
||||
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
|
||||
// Reserved types are treated as null references (§7.5.8.3).
|
||||
_ => {
|
||||
odd.push(num);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
|
||||
if num != 0 {
|
||||
odd.push(num);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *entries.entry(num).or_insert(entry) != entry {
|
||||
odd.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((s.dict.clone(), true))
|
||||
}
|
||||
|
||||
/// Cross-reference data that leaves out objects the file contains: a subsection numbered one
|
||||
/// too low (object 0 in use, the last object without an entry), or an in-use entry at offset 0
|
||||
/// that nothing else replaces. (A number no section lists is not evidence: it is a null
|
||||
/// object, §7.3.10, and valid files have them below an oversized `/Size`.) Viewers rebuild
|
||||
/// the table and show these objects; reading them as null would drop them from every copy.
|
||||
/// When one of those shows, the file is scanned once for object headers, and wrong offsets
|
||||
/// are looked up there (`load`). Only the numbers the damage leaves out take the last
|
||||
/// header's location: one whose entry was set aside, or the last object of a subsection
|
||||
/// numbered one too low. Any other unlisted number stays null, and existing
|
||||
/// entries are never replaced: an object freed by an update stays freed.
|
||||
pub(super) fn fill_xref_gaps(&mut self, log: &mut Vec<String>) {
|
||||
let entries = &self.entries;
|
||||
let shifted = matches!(entries.lookup(0, &self.data), Some(XrefEntry::InFile { .. }));
|
||||
let set_aside = self.xref_oddities.iter().any(|(_, num)| entries.lookup(*num, &self.data).is_none());
|
||||
if !(shifted || set_aside) {
|
||||
return;
|
||||
}
|
||||
let mut located = BTreeMap::new();
|
||||
for (num, offset, generation, _) in object_headers(&self.data) {
|
||||
located.insert(num, (offset, generation));
|
||||
}
|
||||
let mut filled = entries.to_map(&self.data);
|
||||
// A subsection `0 N` whose rows are those of objects 1 to N leaves out object N, whatever
|
||||
// newer sections say about its neighbours.
|
||||
let past_shift: HashSet<u32> = if shifted {
|
||||
self.revisions
|
||||
.iter()
|
||||
.filter(|r| !r.is_stream)
|
||||
.filter_map(|r| usize::try_from(r.xref_offset).ok().map(|o| if r.header_relative { o.saturating_add(self.header_offset) } else { o }))
|
||||
.filter_map(|at| shifted_subsection_end(&self.data, at))
|
||||
.collect()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let odd: HashSet<u32> = self.xref_oddities.iter().map(|(_, n)| *n).collect();
|
||||
let mut found = 0usize;
|
||||
for (&num, &(offset, generation)) in &located {
|
||||
let left_out = past_shift.contains(&num) || odd.contains(&num);
|
||||
if num != 0 && !filled.contains_key(&num) && left_out {
|
||||
filled.insert(num, XrefEntry::InFile { offset, generation });
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
if found > 0 {
|
||||
log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers"));
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled)));
|
||||
}
|
||||
self.located = Arc::new(located);
|
||||
}
|
||||
|
||||
/// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery).
|
||||
pub(super) fn reconstruct(&mut self, log: &mut Vec<String>) -> Result<(), CosError> {
|
||||
let data = self.data.clone();
|
||||
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
|
||||
let mut objstms = Vec::new();
|
||||
// Offsets of objects that may be cross-reference streams: their dictionaries are the
|
||||
// trailers of files that have no `trailer` keyword (§7.5.8).
|
||||
let mut xref_streams = Vec::new();
|
||||
for (num, offset, generation, body) in object_headers(&data) {
|
||||
entries.insert(num, XrefEntry::InFile { offset, generation });
|
||||
objstms.push(num);
|
||||
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
|
||||
&& let Ok(at) = usize::try_from(offset)
|
||||
{
|
||||
xref_streams.push(at);
|
||||
}
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
|
||||
}
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone())));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
|
||||
// Objects inside object streams.
|
||||
for num in objstms {
|
||||
if let Ok(o) = self.try_get(num)
|
||||
&& let Object::Stream(s) = &*o
|
||||
&& s.dict.name(b"Type") == Some(b"ObjStm")
|
||||
&& let Ok(stm) = self.objstm(num)
|
||||
{
|
||||
for (idx, member) in stm.index.iter().enumerate() {
|
||||
if let Some((inner, _)) = member {
|
||||
entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let object_count = entries.len();
|
||||
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
|
||||
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
|
||||
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
|
||||
// /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another
|
||||
// dictionary, such as /Info, wherever it is; an FDF's /Root (no /Type, no /Pages) still
|
||||
// counts when nothing better is there.
|
||||
// A file whose only trailers are cross-reference streams (and whose `startxref` is gone
|
||||
// or points nowhere) keeps its /Info, /ID and /Encrypt this way.
|
||||
let rank = |at: usize, d: &Dict| {
|
||||
let root = self.try_get(d.reference(b"Root")?.num).ok()?;
|
||||
let Object::Dict(c) = &*root else { return None };
|
||||
Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at))
|
||||
};
|
||||
let mut trailer: Option<((bool, usize), Dict)> = None;
|
||||
let mut from = 0;
|
||||
while let Some(p) = find(&data, b"trailer", from, data.len()) {
|
||||
from = p + 7;
|
||||
let mut lx = Lexer::new(&data, from);
|
||||
if let Ok(Object::Dict(d)) = lx.object()
|
||||
&& let Some(r) = rank(p, &d)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
{
|
||||
trailer = Some((r, d));
|
||||
}
|
||||
}
|
||||
for at in xref_streams {
|
||||
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
|
||||
continue;
|
||||
}
|
||||
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams)
|
||||
&& s.dict.name(b"Type") == Some(b"XRef")
|
||||
&& let Some(r) = rank(at, &s.dict)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
{
|
||||
trailer = Some((r, s.dict));
|
||||
}
|
||||
}
|
||||
let trailer = match trailer {
|
||||
Some((_, t)) => t,
|
||||
None => {
|
||||
let catalog = self
|
||||
.entries
|
||||
.object_numbers(&self.data)
|
||||
.into_iter()
|
||||
.find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog")));
|
||||
let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) };
|
||||
let mut t = Dict::new();
|
||||
t.set(b"Root".to_vec(), Object::Ref(ObjRef::new(c, self.generation(c))));
|
||||
t
|
||||
}
|
||||
};
|
||||
let mut trailer = trailer;
|
||||
// What a cross-reference stream's dictionary carries for itself goes, as the chain
|
||||
// readers drop it: a save writes its own.
|
||||
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
|
||||
trailer.remove(k);
|
||||
}
|
||||
self.trailer = trailer;
|
||||
self.revisions = Arc::new(Vec::new());
|
||||
log.push(format!("rebuilt cross-reference index from {object_count} object headers"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// For a classic table at `at` whose first subsection is `0 N` and opens with an in-use row
|
||||
/// (the rows of objects 1 to N), the number N its rows really end at.
|
||||
fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
|
||||
let mut lx = Lexer::new(data, at);
|
||||
if !lx.eat_keyword(b"xref") {
|
||||
return None;
|
||||
}
|
||||
let (Ok(Object::Int(0)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return None };
|
||||
lx.skip_ws();
|
||||
let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u64>().ok());
|
||||
lx.skip_ws();
|
||||
lx.token();
|
||||
lx.skip_ws();
|
||||
match (offset, lx.token()) {
|
||||
(Some(offset), b"n") if offset != 0 => u32::try_from(count).ok().filter(|n| *n > 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position
|
||||
/// after `obj`).
|
||||
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> {
|
||||
let mut headers = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
// Object headers start at a line start (or after whitespace).
|
||||
let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>';
|
||||
if at_start && data[i].is_ascii_digit() {
|
||||
let mut lx = Lexer::new(data, i);
|
||||
let n = lx.token();
|
||||
lx.skip_ws();
|
||||
let g = lx.token();
|
||||
if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
|
||||
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
|
||||
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||
if let Some(num) = num {
|
||||
headers.push((num, i as u64, generation, lx.pos));
|
||||
}
|
||||
i = lx.pos;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// The offset of the section before the one whose trailer this is (`/Prev`), if any. Some writers
|
||||
/// give it as a reference, `N 0 R`. When the sections read so far (`lookup`) place that object
|
||||
/// and it holds an offset (iText's `indirectPrev.pdf`), that is the offset; otherwise, as the
|
||||
/// tiff2pdf files in JHOVE's PDF-HUL-157 cases need, the sections before are at N. Either way the
|
||||
/// reference is noted. Any other value that is not an offset is noted and ends the chain.
|
||||
fn prev_offset(data: &[u8], trailer: &Dict, off: u64, lookup: impl Fn(u32) -> Option<XrefEntry>, log: &mut Vec<String>) -> Option<u64> {
|
||||
match trailer.get(b"Prev") {
|
||||
Some(Object::Int(p)) if *p >= 0 => Some(*p as u64),
|
||||
Some(Object::Ref(r)) => {
|
||||
let held = match lookup(r.num) {
|
||||
Some(XrefEntry::InFile { offset, generation }) if generation == r.generation => {
|
||||
usize::try_from(offset).ok().and_then(|at| parse_indirect(data, at, &|_| None).ok()).and_then(|(at, value)| match value {
|
||||
Object::Int(p) if at == *r && p >= 0 => Some(p as u64),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match held {
|
||||
Some(p) => {
|
||||
log.push(format!(
|
||||
"the trailer at {off} gives /Prev as the reference {} {} R; reading the offset {p} it holds",
|
||||
r.num, r.generation
|
||||
));
|
||||
Some(p)
|
||||
}
|
||||
None => {
|
||||
log.push(format!(
|
||||
"the trailer at {off} gives /Prev as the reference {} {} R; reading it as offset {}",
|
||||
r.num, r.generation, r.num
|
||||
));
|
||||
Some(u64::from(r.num))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
log.push(format!("the trailer at {off} has a /Prev that is not an offset; the sections before it are unreachable"));
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<usize> {
|
||||
let to = to.min(hay.len());
|
||||
if from >= to || needle.is_empty() || to - from < needle.len() {
|
||||
return None;
|
||||
}
|
||||
hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
||||
}
|
||||
|
||||
pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
if hay.len() < needle.len() {
|
||||
return None;
|
||||
}
|
||||
(from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
|
||||
}
|
||||
|
||||
/// A file identifier derived from entropy supplied by the caller.
|
||||
pub(super) fn generated_id(seed: &[u8; 32]) -> Vec<u8> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut out = Vec::new();
|
||||
for i in 0..2u8 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(seed, i, b"pdfcraft id").hash(&mut h);
|
||||
out.extend_from_slice(&h.finish().to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The object number a classic xref subsection really starts at. A common producer bug heads the
|
||||
/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
|
||||
/// every row would be read one number too high and object 1 as free. Such a subsection is read
|
||||
/// from 0 when its second row, read that way, locates an object that is indeed numbered 1, at
|
||||
/// that offset or, as `load` reads offsets, relative to a header shifted by `header_offset`.
|
||||
/// `rows` is the position just after the subsection header.
|
||||
fn subsection_start(data: &[u8], header_offset: usize, rows: usize, start: i64, count: i64) -> i64 {
|
||||
if start != 1 || count < 2 {
|
||||
return start;
|
||||
}
|
||||
let mut lx = Lexer::new(data, rows);
|
||||
let mut row = || {
|
||||
lx.skip_ws();
|
||||
let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u64>().ok());
|
||||
lx.skip_ws();
|
||||
let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u32>().ok());
|
||||
lx.skip_ws();
|
||||
(offset, generation, lx.token() == b"f")
|
||||
};
|
||||
let (Some(0), Some(65535), true) = row() else { return start };
|
||||
let (Some(second), _, false) = row() else { return start };
|
||||
let object_one = |at: usize| {
|
||||
if at >= data.len() {
|
||||
return false;
|
||||
}
|
||||
let mut obj = Lexer::new(data, at);
|
||||
matches!((obj.object(), obj.object()), (Ok(Object::Int(1)), Ok(Object::Int(_)))) && obj.eat_keyword(b"obj")
|
||||
};
|
||||
let Ok(second) = usize::try_from(second) else { return start };
|
||||
if object_one(second) || (header_offset != 0 && object_one(second.saturating_add(header_offset))) { 0 } else { start }
|
||||
}
|
||||
|
||||
/// Object numbers must fit the representation used by references and the xref map.
|
||||
/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor
|
||||
/// truncation can turn a damaged range into entries for unrelated objects.
|
||||
fn valid_xref_range(start: i64, count: i64) -> bool {
|
||||
u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1)
|
||||
}
|
||||
|
||||
/// The most indirect objects a document can have (the classic implementation limit, ISO
|
||||
/// 32000-1 Annex C): it bounds how much data a cross-reference stream can hold.
|
||||
const MAX_XREF_OBJECTS: usize = 8_388_607;
|
||||
@@ -0,0 +1,210 @@
|
||||
use super::xref::XrefEntry;
|
||||
use super::*;
|
||||
|
||||
thread_local! {
|
||||
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
|
||||
static LOADING: std::cell::RefCell<Vec<u32>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
/// Indirect `/Length` values resolved during the current outermost load: a damaged file
|
||||
/// retries a parse several ways, and each retry would resolve the same lengths again
|
||||
/// (exponential in the chain of streams whose lengths point at further streams).
|
||||
pub(super) static LENGTHS: std::cell::RefCell<HashMap<u32, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream
|
||||
/// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is
|
||||
/// bounded as well.
|
||||
pub(super) struct LoadGuard;
|
||||
|
||||
impl LoadGuard {
|
||||
const MAX: usize = 32;
|
||||
|
||||
pub(super) fn enter(num: u32) -> Option<Self> {
|
||||
LOADING.with(|l| {
|
||||
let mut l = l.borrow_mut();
|
||||
if l.len() >= Self::MAX || l.contains(&num) {
|
||||
return None;
|
||||
}
|
||||
l.push(num);
|
||||
Some(LoadGuard)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoadGuard {
|
||||
fn drop(&mut self) {
|
||||
let outermost = LOADING.with(|l| {
|
||||
let mut l = l.borrow_mut();
|
||||
l.pop();
|
||||
l.is_empty()
|
||||
});
|
||||
if outermost {
|
||||
LENGTHS.with(|m| m.borrow_mut().clear());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ObjStm {
|
||||
/// One slot per member in stream order, since cross-reference entries name a member by its
|
||||
/// position: (object number, byte offset within `data`), or `None` for a member whose number
|
||||
/// or offset is unusable ([`objstm_member`]).
|
||||
pub(super) index: Vec<Option<(u32, usize)>>,
|
||||
pub(super) data: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Where member `num` of an object stream starts: its `offset` past `/First` (`first`), when the
|
||||
/// number is an object number and the start lies inside the decoded `len` bytes. Checked on every
|
||||
/// target, so a 32-bit build neither truncates nor wraps a large `/First` or offset (#1815).
|
||||
pub(super) fn objstm_member(num: i64, offset: i64, first: Option<usize>, len: usize) -> Option<(u32, usize)> {
|
||||
let num = u32::try_from(num).ok()?;
|
||||
let start = first?.checked_add(usize::try_from(offset).ok()?)?;
|
||||
(start < len).then_some((num, start))
|
||||
}
|
||||
|
||||
/// Where an overlay slot came from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Slot {
|
||||
Set(u16, Arc<Object>),
|
||||
Freed(u16),
|
||||
}
|
||||
|
||||
/// An immutable input/decode context shared by document clones. Holding this identity
|
||||
/// retains neither the input bytes nor the parsed document. Weak ownership prevents an
|
||||
/// expired identity from matching a later allocation at the same address.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SourceIdentity(pub(super) Weak<()>);
|
||||
|
||||
impl PartialEq for SourceIdentity {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.ptr_eq(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for SourceIdentity {}
|
||||
|
||||
/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed
|
||||
/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every
|
||||
/// change makes a different one. Changed objects are compared as allocations, which the state
|
||||
/// keeps alive, so a later object can't take an address one of them had.
|
||||
#[derive(Clone)]
|
||||
pub struct ReadState {
|
||||
pub(super) source: SourceIdentity,
|
||||
pub(super) trailer: Dict,
|
||||
pub(super) overlay: BTreeMap<u32, Slot>,
|
||||
}
|
||||
|
||||
impl ReadState {
|
||||
/// Whether `doc` reads as this state.
|
||||
pub fn matches(&self, doc: &Document) -> bool {
|
||||
self.source == doc.source_identity()
|
||||
&& self.trailer == doc.trailer
|
||||
&& self.overlay.len() == doc.overlay.len()
|
||||
&& self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| {
|
||||
a == b
|
||||
&& match (x, y) {
|
||||
(Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p),
|
||||
(Slot::Freed(g), Slot::Freed(h)) => g == h,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ReadState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A temporary reader for whole-document work. Its caches never populate the editor or
|
||||
/// undo snapshots. Returned objects survive cache eviction; a stream read from the file is a
|
||||
/// view into the input bytes (it keeps the whole input alive while held, not a copy).
|
||||
pub(crate) struct ObjectReader {
|
||||
pub(crate) document: Document,
|
||||
pub(super) decoded_count: usize,
|
||||
}
|
||||
|
||||
impl ObjectReader {
|
||||
pub(super) const OBJECTS: usize = 128;
|
||||
pub(super) const STREAM_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
pub(crate) fn get(&mut self, reference: ObjRef) -> Arc<Object> {
|
||||
self.try_get(reference).unwrap_or_else(|_| Arc::new(Object::Null))
|
||||
}
|
||||
|
||||
pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result<Arc<Object>, CosError> {
|
||||
let object = self.document.try_get(reference.num);
|
||||
// A missed eviction here only costs a re-parse, so never fail the read on a poisoned
|
||||
// lock. The editing-cache evictors propagate instead; they guard user edits.
|
||||
if let Ok(mut streams) = self.document.objstms.lock() {
|
||||
// Only insertion changes this private cache between reads. Most neighboring
|
||||
// objects reuse a decoded stream: do not sum the whole store for every object.
|
||||
if streams.len() != self.decoded_count {
|
||||
let bytes = streams.values().fold(0usize, |bytes, stream| {
|
||||
bytes
|
||||
.saturating_add(stream.data.capacity())
|
||||
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<Option<(u32, usize)>>()))
|
||||
});
|
||||
if bytes > Self::STREAM_BYTES || streams.len() >= Self::OBJECTS {
|
||||
// Retain one oversized active stream so its next object does not require
|
||||
// decoding it again. That stream still obeys the document's decode cap.
|
||||
let current = match self.document.xref_entry(reference.num) {
|
||||
Some(XrefEntry::InStream { stream, .. }) => Some(stream),
|
||||
_ => None,
|
||||
};
|
||||
streams.retain(|num, _| Some(*num) == current);
|
||||
}
|
||||
self.decoded_count = streams.len();
|
||||
}
|
||||
}
|
||||
// Parsed objects stay cached (an indirect /Length or a dictionary read again costs a
|
||||
// lookup, not a parse) until the cache passes its object count or holds more than
|
||||
// STREAM_BYTES of data of its own. Streams read from the file are views into the
|
||||
// input bytes, which the document keeps anyway, so they don't count; decrypted
|
||||
// streams and strings do. Checked after failed loads too: resolving a /Length may
|
||||
// have cached large objects.
|
||||
if let Ok(mut cache) = self.document.cache.lock() {
|
||||
let data = &self.document.data;
|
||||
if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
object
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Cross-reference sections read on this thread.
|
||||
pub(super) static SECTION_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object
|
||||
/// is measured in full.
|
||||
const OWNED_BYTES_DEPTH: usize = 256;
|
||||
|
||||
/// Bytes `object` holds of its own: string and stream data that is not a view into `data`
|
||||
/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured
|
||||
/// and counts as too large to keep.
|
||||
pub(super) fn owned_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
|
||||
if depth > OWNED_BYTES_DEPTH {
|
||||
return usize::MAX;
|
||||
}
|
||||
match object {
|
||||
Object::String(s) => s.bytes.len(),
|
||||
Object::Stream(s) => {
|
||||
let own = if s.raw.shares(data) { 0 } else { s.raw.len() };
|
||||
s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1)))
|
||||
}
|
||||
Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))),
|
||||
Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How an encrypted document is opened: with a password, or with the file key of a document
|
||||
/// that has the same encryption (an earlier revision of the same file).
|
||||
pub(super) enum Credentials<'a> {
|
||||
Password(Option<&'a str>),
|
||||
Handler { handler: &'a Arc<pdfcraft_crypt::SecurityHandler>, id0: Vec<u8> },
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum XrefEntry {
|
||||
Free { next_generation: u16 },
|
||||
InFile { offset: u64, generation: u16 },
|
||||
InStream { stream: u32, index: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct XrefRange {
|
||||
pub(super) start: u32,
|
||||
pub(super) count: u32,
|
||||
pub(super) offset: usize,
|
||||
pub(super) stride: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum XrefSectionData {
|
||||
Table,
|
||||
Stream { bytes: Arc<Vec<u8>>, widths: [usize; 3] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct XrefSection {
|
||||
pub(super) data: XrefSectionData,
|
||||
pub(super) ranges: Vec<XrefRange>,
|
||||
pub(super) row_count: usize,
|
||||
pub(super) max_num: u32,
|
||||
pub(super) has_entries: bool,
|
||||
/// A table subsection was read one number lower than it says ([`subsection_start`]).
|
||||
pub(super) renumbered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct XrefRevisionSection {
|
||||
pub(super) primary: XrefSection,
|
||||
pub(super) hybrid: Option<XrefSection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum XrefIndex {
|
||||
Compact { revisions: Vec<XrefRevisionSection>, max_num: u32, has_entries: bool },
|
||||
Materialized(Arc<BTreeMap<u32, XrefEntry>>),
|
||||
}
|
||||
|
||||
pub(super) const MAX_COMPACT_XREF_ROWS: usize = 2_000_000;
|
||||
|
||||
pub(super) const MAX_COMPACT_XREF_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
impl XrefSection {
|
||||
pub(super) fn has_any_entry(&self) -> bool {
|
||||
match &self.data {
|
||||
XrefSectionData::Table => self.has_entries,
|
||||
XrefSectionData::Stream { bytes, widths } => {
|
||||
let row_width = widths.iter().sum::<usize>();
|
||||
if row_width == 0 {
|
||||
return false;
|
||||
}
|
||||
for range in &self.ranges {
|
||||
let available = bytes.len().saturating_sub(range.offset) / row_width;
|
||||
for i in 0..available.min(range.count as usize) {
|
||||
let Some(offset) = i.checked_mul(row_width).and_then(|delta| range.offset.checked_add(delta)) else { continue };
|
||||
let Some(row) = bytes.get(offset..offset.saturating_add(row_width)) else { continue };
|
||||
let read_field = |from: usize, width: usize, default: u64| -> Option<u64> {
|
||||
if width == 0 {
|
||||
return Some(default);
|
||||
}
|
||||
let end = from.checked_add(width)?;
|
||||
Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte)))
|
||||
};
|
||||
let Some(kind) = read_field(0, widths[0], 1) else { continue };
|
||||
if kind == 0 || kind == 2 {
|
||||
return true;
|
||||
}
|
||||
if kind == 1 && read_field(widths[0], widths[1], 0).is_some_and(|offset| offset != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn retained_bytes(&self) -> usize {
|
||||
match &self.data {
|
||||
XrefSectionData::Table => 0,
|
||||
XrefSectionData::Stream { bytes, .. } => bytes.capacity(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option<XrefEntry> {
|
||||
let after = self.ranges.partition_point(|range| range.start <= num);
|
||||
let range = self.ranges.get(after.checked_sub(1)?)?;
|
||||
let end = u64::from(range.start).checked_add(u64::from(range.count))?;
|
||||
if u64::from(num) >= end {
|
||||
return None;
|
||||
}
|
||||
let row = usize::try_from(num.checked_sub(range.start)?).ok()?;
|
||||
let offset = range.offset.checked_add(row.checked_mul(range.stride)?)?;
|
||||
match &self.data {
|
||||
XrefSectionData::Table => {
|
||||
let mut lexer = Lexer::new(file, offset);
|
||||
lexer.skip_ws();
|
||||
let object_offset = lexer.token();
|
||||
lexer.skip_ws();
|
||||
let generation = lexer.token();
|
||||
lexer.skip_ws();
|
||||
let kind = lexer.token();
|
||||
match kind {
|
||||
b"n" => {
|
||||
let object_offset = std::str::from_utf8(object_offset).ok()?.parse::<u64>().ok()?;
|
||||
if object_offset == 0 {
|
||||
return None;
|
||||
}
|
||||
let generation = std::str::from_utf8(generation).ok()?.parse::<u32>().unwrap_or(0).min(u16::MAX as u32) as u16;
|
||||
Some(XrefEntry::InFile { offset: object_offset, generation })
|
||||
}
|
||||
b"f" => {
|
||||
let generation = std::str::from_utf8(generation).ok()?.parse::<u32>().unwrap_or(0).min(u16::MAX as u32) as u16;
|
||||
Some(XrefEntry::Free { next_generation: generation })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
XrefSectionData::Stream { bytes, widths } => {
|
||||
let row_width = widths.iter().sum::<usize>();
|
||||
let end = offset.checked_add(row_width)?;
|
||||
let row = bytes.get(offset..end)?;
|
||||
let field = |from: usize, width: usize, default: u64| -> Option<u64> {
|
||||
if width == 0 {
|
||||
return Some(default);
|
||||
}
|
||||
let end = from.checked_add(width)?;
|
||||
Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte)))
|
||||
};
|
||||
let kind = field(0, widths[0], 1)?;
|
||||
let a = field(widths[0], widths[1], 0)?;
|
||||
let b = field(widths[0] + widths[1], widths[2], 0)?;
|
||||
let entry = match kind {
|
||||
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
|
||||
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
|
||||
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
|
||||
_ => return None,
|
||||
};
|
||||
(!matches!(entry, XrefEntry::InFile { offset: 0, .. })).then_some(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extend_numbers(&self, numbers: &mut Vec<u32>) {
|
||||
for range in &self.ranges {
|
||||
let available = match &self.data {
|
||||
XrefSectionData::Table => range.count as usize,
|
||||
XrefSectionData::Stream { bytes, widths } => {
|
||||
let row_width = widths.iter().sum::<usize>();
|
||||
bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0)
|
||||
}
|
||||
};
|
||||
for i in 0..available.min(range.count as usize) {
|
||||
if let Ok(i) = u32::try_from(i)
|
||||
&& let Some(num) = range.start.checked_add(i)
|
||||
{
|
||||
numbers.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl XrefRevisionSection {
|
||||
pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option<XrefEntry> {
|
||||
let primary = self.primary.lookup(num, file);
|
||||
let hybrid = self.hybrid.as_ref().and_then(|section| section.lookup(num, file));
|
||||
match primary {
|
||||
None | Some(XrefEntry::Free { .. }) => hybrid.or(primary),
|
||||
entry => entry,
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_numbers(&self, numbers: &mut Vec<u32>) {
|
||||
self.primary.extend_numbers(numbers);
|
||||
if let Some(hybrid) = &self.hybrid {
|
||||
hybrid.extend_numbers(numbers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl XrefIndex {
|
||||
pub(super) fn empty() -> Self {
|
||||
Self::Materialized(Arc::new(BTreeMap::new()))
|
||||
}
|
||||
|
||||
pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option<XrefEntry> {
|
||||
match self {
|
||||
Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)),
|
||||
Self::Materialized(entries) => entries.get(&num).copied(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::Compact { has_entries, .. } => !has_entries,
|
||||
Self::Materialized(entries) => entries.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop an entry for object number 4294967295, which no incremental `/Size` can cover.
|
||||
/// Only a materialized index can hold one: the compact reader requires `max_num < /Size`.
|
||||
pub(super) fn remove_exhausted(&mut self) -> bool {
|
||||
match self {
|
||||
Self::Compact { .. } => false,
|
||||
Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entry, newest section first, as the materialized reader keeps them.
|
||||
pub(super) fn to_map(&self, file: &[u8]) -> BTreeMap<u32, XrefEntry> {
|
||||
match self {
|
||||
Self::Materialized(entries) => entries.as_ref().clone(),
|
||||
Self::Compact { revisions, .. } => {
|
||||
let mut numbers = Vec::new();
|
||||
for revision in revisions {
|
||||
revision.extend_numbers(&mut numbers);
|
||||
}
|
||||
numbers.into_iter().filter_map(|num| self.lookup(num, file).map(|entry| (num, entry))).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn max_num(&self) -> u32 {
|
||||
match self {
|
||||
Self::Compact { max_num, .. } => *max_num,
|
||||
Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn object_numbers(&self, file: &[u8]) -> Vec<u32> {
|
||||
match self {
|
||||
Self::Materialized(entries) => {
|
||||
entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect()
|
||||
}
|
||||
Self::Compact { revisions, .. } => {
|
||||
if let [revision] = revisions.as_slice()
|
||||
&& revision.hybrid.is_none()
|
||||
{
|
||||
let mut numbers = Vec::with_capacity(revision.primary.row_count);
|
||||
revision.primary.extend_numbers(&mut numbers);
|
||||
numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. })));
|
||||
return numbers;
|
||||
}
|
||||
let mut numbers = Vec::new();
|
||||
for revision in revisions {
|
||||
revision.extend_numbers(&mut numbers);
|
||||
}
|
||||
numbers.sort_unstable();
|
||||
numbers.dedup();
|
||||
numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. })));
|
||||
numbers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cross-reference section of the file (one per save; the first is the original).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Revision {
|
||||
/// Offset of the xref section (`startxref` value).
|
||||
pub xref_offset: u64,
|
||||
/// `true` if the section is a cross-reference stream.
|
||||
pub is_stream: bool,
|
||||
/// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the
|
||||
/// file. The two differ only in a file with bytes before its header, and an update appended
|
||||
/// to such a file must count the same way as the section it points back to.
|
||||
pub header_relative: bool,
|
||||
}
|
||||
@@ -2,17 +2,20 @@
|
||||
//!
|
||||
//! * `write_incremental` appends the edited objects, a cross-reference section in the same style
|
||||
//! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are
|
||||
//! never modified, which keeps digital signatures over earlier revisions valid.
|
||||
//! never modified, which keeps digital signatures over earlier revisions valid. With
|
||||
//! `SaveOptions::drop_unlinked`, objects added since the file was read that nothing refers to
|
||||
//! any more are left out (written as free).
|
||||
//! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a
|
||||
//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer
|
||||
//! entries are copied, including objects reachable only from them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::CosError;
|
||||
use crate::document::{Document, ObjectReader};
|
||||
use crate::document::{Document, ObjectReader, XrefEntry};
|
||||
use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
/// Serialize one object. Deterministic: same object → same bytes.
|
||||
@@ -156,11 +159,18 @@ pub struct SaveOptions {
|
||||
/// Full saves: pack non-stream objects into compressed object streams with a cross-reference
|
||||
/// stream (PDF 1.5+, §7.5.7–7.5.8). Off writes a classic table that any reader accepts.
|
||||
pub object_streams: bool,
|
||||
/// Incremental saves: leave out what was added since the file was read and nothing refers to
|
||||
/// any more, such as a comment added and deleted again or a redaction mark added and cleared
|
||||
/// (written as free). Off by default, because a writer may add an object only it finds again
|
||||
/// by scanning, as a standalone document timestamp is; such a writer frees the leftovers
|
||||
/// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave
|
||||
/// these out.
|
||||
pub drop_unlinked: bool,
|
||||
}
|
||||
|
||||
impl Default for SaveOptions {
|
||||
fn default() -> Self {
|
||||
Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true }
|
||||
Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +219,14 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64;
|
||||
let mut out = Vec::new();
|
||||
let mut offsets: BTreeMap<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
|
||||
let unlinked = if opts.drop_unlinked { unlinked_additions(&doc) } else { HashSet::new() };
|
||||
for (num, generation, obj) in doc.overlay_entries() {
|
||||
match obj {
|
||||
// Written as free, so `/Size` still covers every number this document handed out. The
|
||||
// generation stays: the number was never in use, and an undo can bring it back as is.
|
||||
Some(_) if unlinked.contains(&num) => {
|
||||
offsets.insert(num, (0, generation, false));
|
||||
}
|
||||
Some(o) => {
|
||||
offsets.insert(num, (base + out.len() as u64, generation, true));
|
||||
write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out);
|
||||
@@ -387,7 +403,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
/// Object `r` read again for writing. The traversal read it once and only kept its number, so
|
||||
/// a second read that fails, finds nothing or finds a different kind of object must fail the
|
||||
/// save rather than write `null` in its place.
|
||||
fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result<std::sync::Arc<Object>, CosError> {
|
||||
fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result<Arc<Object>, CosError> {
|
||||
let o = reader.try_get(r)?;
|
||||
if matches!(*o, Object::Null) || matches!(*o, Object::Stream(_)) != is_stream {
|
||||
return Err(CosError::Syntax { offset: 0, detail: format!("object {} read differently while saving; nothing was written", r.num) });
|
||||
@@ -439,6 +455,37 @@ fn output_number(index: usize) -> Result<u32, CosError> {
|
||||
u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() })
|
||||
}
|
||||
|
||||
/// Objects added since the file was read (their numbers are not in use in it) that nothing an
|
||||
/// incremental update writes refers to: what one edit added and a later one unlinked, such as a
|
||||
/// deleted comment or a cleared redaction mark. The file never held them, so leaving them out
|
||||
/// loses nothing, while writing them would keep what the user removed. Only the update is walked:
|
||||
/// from the trailer and the file's changed objects through additions. An unchanged object of the
|
||||
/// file is not followed: a reference it holds to such a number already reads as null in the file
|
||||
/// (a damaged file's dangling reference), and it still does.
|
||||
pub(crate) fn unlinked_additions(doc: &Document) -> HashSet<u32> {
|
||||
let added = |num: u32| matches!(doc.xref_entry(num), None | Some(XrefEntry::Free { .. }));
|
||||
let mut pending: HashMap<u32, Arc<Object>> =
|
||||
doc.overlay_entries().filter_map(|(num, _, o)| Some((num, o?))).filter(|(num, _)| added(*num)).collect();
|
||||
if pending.is_empty() {
|
||||
return HashSet::new();
|
||||
}
|
||||
let mut stack: Vec<ObjRef> = Vec::new();
|
||||
doc.trailer().iter().for_each(|(_, v)| collect_refs(v, &mut |r| stack.push(r)));
|
||||
for (num, _, o) in doc.overlay_entries() {
|
||||
if let Some(o) = o
|
||||
&& !added(num)
|
||||
{
|
||||
collect_refs(&o, &mut |r| stack.push(r));
|
||||
}
|
||||
}
|
||||
while let Some(r) = stack.pop() {
|
||||
if let Some(o) = pending.remove(&r.num) {
|
||||
collect_refs(&o, &mut |r| stack.push(r));
|
||||
}
|
||||
}
|
||||
pending.into_keys().collect()
|
||||
}
|
||||
|
||||
fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) {
|
||||
match o {
|
||||
Object::Ref(r) => f(*r),
|
||||
|
||||
@@ -228,6 +228,63 @@ fn incremental_save_keeps_a_trailer_only_edit() {
|
||||
assert_qpdf_clean(&saved);
|
||||
}
|
||||
|
||||
/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is
|
||||
/// not written: the file never held it, and writing it would keep what was removed. Additions the
|
||||
/// update still refers to, from the trailer or a changed object and through other additions, are.
|
||||
#[test]
|
||||
fn incremental_save_leaves_out_additions_nothing_refers_to() {
|
||||
let mut doc = Document::open(Arc::new(classic_table())).unwrap();
|
||||
let text = |s: &str| Object::String(pdfcraft_cos::PdfString::literal(s.as_bytes().to_vec()));
|
||||
let dict = |entries: &[(&str, Object)]| {
|
||||
let mut d = Dict::new();
|
||||
entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone()));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let page_obj = ObjRef::new(3, 0);
|
||||
let leaf = doc.add(text("kept leaf"));
|
||||
let kept = doc.add(dict(&[("Leaf", Object::Ref(leaf))]));
|
||||
doc.update_dict(page_obj, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(kept))).unwrap();
|
||||
let by_trailer = doc.add(text("kept by the trailer"));
|
||||
doc.trailer_mut().set(b"Custom".to_vec(), Object::Ref(by_trailer));
|
||||
// Linked from the page and unlinked again, as clearing a mark does.
|
||||
let dropped_leaf = doc.add(text("dropped leaf"));
|
||||
let dropped = doc.add(dict(&[("Leaf", Object::Ref(dropped_leaf))]));
|
||||
doc.update_dict(page_obj, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(dropped)]))).unwrap();
|
||||
doc.update_dict(page_obj, |d| {
|
||||
d.remove(b"Annots");
|
||||
})
|
||||
.unwrap();
|
||||
// Unlinked additions that refer to each other.
|
||||
let first = doc.add(Object::Null);
|
||||
let second = doc.add(dict(&[("Next", Object::Ref(first)), ("Note", text("dropped cycle"))]));
|
||||
doc.set(first, dict(&[("Next", Object::Ref(second))]));
|
||||
|
||||
// Off by default: a writer may add what only it finds again, by scanning.
|
||||
let kept_all = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(kept_all.windows(12).any(|w| w == b"dropped leaf"));
|
||||
let saved = write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() }).unwrap();
|
||||
let holds = |s: &str| saved.windows(s.len()).any(|w| w == s.as_bytes());
|
||||
assert!(holds("kept leaf") && holds("kept by the trailer"));
|
||||
assert!(!holds("dropped leaf") && !holds("dropped cycle"), "unlinked additions are written");
|
||||
let back = Document::open(Arc::new(saved.clone())).unwrap();
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(back.get(leaf).as_string().map(|s| s.bytes.as_slice()), Some(b"kept leaf".as_slice()));
|
||||
for gone in [dropped, dropped_leaf, first, second] {
|
||||
assert_eq!(*back.get(gone), Object::Null);
|
||||
}
|
||||
assert!(!page(&back).contains(b"Annots"));
|
||||
assert_qpdf_clean(&saved);
|
||||
|
||||
// Freed beforehand, they are written the same way without the option, and what is added
|
||||
// after that, nothing referring to it either (a document timestamp), is kept.
|
||||
let mut freed = doc.clone();
|
||||
freed.free_unlinked_additions();
|
||||
assert_eq!(write_incremental(&freed, &SaveOptions::default()).unwrap(), saved, "the same objects, freed the same way");
|
||||
freed.add(text("added after"));
|
||||
let stamped = write_incremental(&freed, &SaveOptions::default()).unwrap();
|
||||
assert!(stamped.windows(11).any(|w| w == b"added after") && !stamped.windows(12).any(|w| w == b"dropped leaf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qpdf_accepts_object_stream_output() {
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
|
||||
+13
-1190
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,383 @@
|
||||
use super::layout::{
|
||||
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
|
||||
};
|
||||
use super::types::{AddedText, Align, Family};
|
||||
use super::*;
|
||||
|
||||
fn font_name(base: &str) -> String {
|
||||
format!("PCF{}", base.replace('-', ""))
|
||||
}
|
||||
|
||||
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font
|
||||
/// per 240 distinct glyphs it needs.
|
||||
pub(super) const GLYPHS_PER_FONT: usize = 240;
|
||||
|
||||
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
|
||||
/// its pieces in drawing order and its width at the item's size.
|
||||
pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
|
||||
|
||||
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
|
||||
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
|
||||
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
|
||||
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||||
let laid = wrapped(t)
|
||||
.into_iter()
|
||||
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
|
||||
}
|
||||
|
||||
/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines
|
||||
/// for a script's face (the craft-fonts faces, or a stand-in in tests).
|
||||
pub(super) fn draw_laid_shaped(
|
||||
doc: &mut Document,
|
||||
t: &AddedText,
|
||||
laid: &[LaidLine],
|
||||
taken: &Dict,
|
||||
fonts: &mut Dict,
|
||||
out: &mut Vec<u8>,
|
||||
outline: impl Fn(ShapedScript, u32) -> Result<GlyphOutline, GlyphError>,
|
||||
) -> Result<(), EditError> {
|
||||
let r = text_rect(t);
|
||||
let latin = font_name(t.family.base_font(t.bold, t.italic));
|
||||
// Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of
|
||||
// one of the fonts, so copy and search get each character exactly once.
|
||||
let mut keys: Vec<&ShapedCluster> = Vec::new();
|
||||
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
|
||||
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
|
||||
Piece::Shaped(clusters) => clusters.as_slice(),
|
||||
Piece::Latin(_) => &[],
|
||||
}) {
|
||||
index.entry(cluster_key(c)).or_insert_with(|| {
|
||||
keys.push(c);
|
||||
keys.len() - 1
|
||||
});
|
||||
}
|
||||
|
||||
// Every outline is read before anything is written, so a failure leaves the document as it was.
|
||||
let mut outlines = HashMap::new();
|
||||
for c in &keys {
|
||||
for (id, _) in &c.glyphs {
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
|
||||
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
|
||||
ShapedScript::Arabic => arabic_error(e, &c.text),
|
||||
ShapedScript::Hebrew => hebrew_error(e, &c.text),
|
||||
ShapedScript::Thai => thai_error(e, &c.text),
|
||||
})?);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The names the page and this item already use: a new font never takes one of them.
|
||||
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
|
||||
let mut names = Vec::new();
|
||||
for chunk in keys.chunks(GLYPHS_PER_FONT) {
|
||||
let font = type3_shaped(doc, chunk, &outlines);
|
||||
// A name no font on the page has, so items never replace each other's fonts (object
|
||||
// numbers alone don't do: a full save renumbers objects but keeps resource names).
|
||||
let mut name = format!("PCAr{}", font.num);
|
||||
let mut suffix = 0usize;
|
||||
while taken_names.contains(name.as_bytes()) {
|
||||
suffix = suffix.saturating_add(1);
|
||||
name = format!("PCAr{}_{suffix}", font.num);
|
||||
}
|
||||
taken_names.insert(name.clone().into_bytes());
|
||||
fonts.set(name.clone().into_bytes(), Object::Ref(font));
|
||||
names.push(name);
|
||||
}
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
|
||||
// Shaped glyphs are painted in visual order. Keep the original line's typing order
|
||||
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
|
||||
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||||
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
|
||||
let spaces = line.matches(' ').count();
|
||||
// Justified: the spaces of every line but the last stretch to fill the box.
|
||||
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
let x = match t.align {
|
||||
// A right-to-left paragraph's unstretched last line ends at the right edge.
|
||||
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
|
||||
Align::Left | Align::Justify => r[0],
|
||||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||||
Align::Right => r[2] - w,
|
||||
};
|
||||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||||
let mut pen = x;
|
||||
let mut current: Option<&str> = None;
|
||||
for piece in pieces {
|
||||
match piece {
|
||||
Piece::Latin(s) => {
|
||||
out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes());
|
||||
out.extend(literal(&win_ansi(s)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
current = Some(latin.as_str());
|
||||
pen += latin_width(t, s) + tw * s.matches(' ').count() as f64;
|
||||
}
|
||||
Piece::Shaped(clusters) => {
|
||||
for c in clusters {
|
||||
let Some(&k) = index.get(&cluster_key(c)) else { continue };
|
||||
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
|
||||
// Codes start at 1; k % 240 + 1 is at most 240.
|
||||
let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1);
|
||||
if current != Some(name.as_str()) {
|
||||
out.extend(format!("/{name} {} Tf ", n(t.size)).bytes());
|
||||
current = Some(name.as_str());
|
||||
}
|
||||
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
|
||||
out.extend(literal(&[code]));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b"EMC\n");
|
||||
}
|
||||
out.extend_from_slice(b"ET\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
|
||||
/// equal clusters come from the same font units).
|
||||
type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);
|
||||
|
||||
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
|
||||
(c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
|
||||
}
|
||||
|
||||
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
|
||||
fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef {
|
||||
let mut charprocs = Dict::new();
|
||||
let mut differences = vec![Object::Int(1)];
|
||||
let mut widths = Vec::with_capacity(clusters.len());
|
||||
let mut bbox = [0.0f64; 4];
|
||||
let mut to_unicode = Vec::new();
|
||||
for (i, c) in clusters.iter().enumerate() {
|
||||
let code = i + 1;
|
||||
// Glyph space is 1000 units per em.
|
||||
let mut paths = Vec::new();
|
||||
let mut ink: Option<[f64; 4]> = None;
|
||||
for (id, [dx, dy]) in &c.glyphs {
|
||||
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
|
||||
for contour in &g.contours {
|
||||
for (j, p) in contour.iter().enumerate() {
|
||||
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
|
||||
}
|
||||
paths.extend_from_slice(b"h\n");
|
||||
let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy];
|
||||
ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]));
|
||||
}
|
||||
}
|
||||
// Rounded outwards so no ink is clipped.
|
||||
let [x0, y0, x1, y1] =
|
||||
ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]);
|
||||
bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)];
|
||||
let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes();
|
||||
if !paths.is_empty() {
|
||||
proc.extend(paths);
|
||||
proc.extend_from_slice(b"f\n");
|
||||
}
|
||||
let glyph = format!("g{code:02X}");
|
||||
charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc)))));
|
||||
differences.push(Object::name(&glyph));
|
||||
widths.push(Object::Real((c.advance * 1000.0).round()));
|
||||
if !c.text.is_empty() {
|
||||
let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||||
to_unicode.push(format!("<{code:02X}> <{hex}>\n"));
|
||||
}
|
||||
}
|
||||
let mut cmap = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
|
||||
);
|
||||
// A CMap block holds at most 100 entries.
|
||||
for block in to_unicode.chunks(100) {
|
||||
cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat()));
|
||||
}
|
||||
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
|
||||
// The face's family when every glyph comes from one script; without the face, the script's
|
||||
// name (as Arabic items were always named).
|
||||
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
|
||||
Some(script) => script.font().map_or(
|
||||
match script {
|
||||
ShapedScript::Arabic => "Arabic",
|
||||
ShapedScript::Hebrew => "Hebrew",
|
||||
ShapedScript::Thai => "Thai",
|
||||
},
|
||||
|f| f.family,
|
||||
),
|
||||
None => "PdfCraft Shaped Text",
|
||||
};
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
|
||||
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec())));
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(4));
|
||||
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
|
||||
let mut encoding = Dict::new();
|
||||
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
|
||||
encoding.set(b"Differences".to_vec(), Object::Array(differences));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type3"));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
|
||||
font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
|
||||
font.set(
|
||||
b"FontMatrix".to_vec(),
|
||||
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
|
||||
);
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(1));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64));
|
||||
font.set(b"Widths".to_vec(), Object::Array(widths));
|
||||
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
|
||||
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
|
||||
doc.add(Object::Dict(font))
|
||||
}
|
||||
|
||||
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
|
||||
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
|
||||
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
|
||||
/// move it.
|
||||
pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {
|
||||
let mut res = Dict::new();
|
||||
let mut out = format!(
|
||||
"q {} {} {} {} {} {} cm\n",
|
||||
matrix_number(view[0]),
|
||||
matrix_number(view[1]),
|
||||
matrix_number(view[2]),
|
||||
matrix_number(view[3]),
|
||||
matrix_number(view[4]),
|
||||
matrix_number(view[5])
|
||||
)
|
||||
.into_bytes();
|
||||
match c {
|
||||
Content::Text(t) => {
|
||||
let base = t.family.base_font(t.bold, t.italic);
|
||||
let name = font_name(base);
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
if t.family != Family::Courier || !t.text.is_ascii() {
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
}
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(name.clone().into_bytes(), Object::Dict(font));
|
||||
let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing);
|
||||
if t.text.chars().any(is_thai)
|
||||
|| face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some())
|
||||
|| face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some())
|
||||
{
|
||||
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
out.extend_from_slice(b"Q\n");
|
||||
return Ok((out, res));
|
||||
}
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let r = text_rect(t);
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
let all = lines(t);
|
||||
for (i, line) in all.iter().enumerate() {
|
||||
let w = latin_width(t, line);
|
||||
let x = match t.align {
|
||||
Align::Left | Align::Justify => r[0],
|
||||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||||
Align::Right => r[2] - w,
|
||||
};
|
||||
// Baseline: 0.8 em below the line top.
|
||||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||||
// Justified: word spacing makes every line but the last fill the box.
|
||||
let spaces = line.matches(' ').count();
|
||||
let tw =
|
||||
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
|
||||
out.extend(literal(&win_ansi(line)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
}
|
||||
out.extend_from_slice(b"ET\n");
|
||||
}
|
||||
Content::Image(i) => {
|
||||
if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) {
|
||||
return Err(EditError::Invalid("not an image".into()));
|
||||
}
|
||||
let r = norm(i.rect);
|
||||
let name = format!("PCImg{}", i.image.num);
|
||||
let mut xo = Dict::new();
|
||||
xo.set(name.clone().into_bytes(), Object::Ref(i.image));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
let [a, b, c, d, e, f] = i.matrix().0;
|
||||
// Clip to the box: the cropped-away parts stay hidden.
|
||||
out.extend(
|
||||
format!(
|
||||
"{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n",
|
||||
n(r[0]),
|
||||
n(r[1]),
|
||||
n(r[2] - r[0]),
|
||||
n(r[3] - r[1]),
|
||||
n(a),
|
||||
n(b),
|
||||
n(c),
|
||||
n(d),
|
||||
n(e),
|
||||
n(f)
|
||||
)
|
||||
.bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b"Q\n");
|
||||
Ok((out, res))
|
||||
}
|
||||
|
||||
pub(super) fn params(c: &Content) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
|
||||
match c {
|
||||
Content::Text(t) => {
|
||||
d.set(b"Kind".to_vec(), Object::name("Text"));
|
||||
d.set(b"Text".to_vec(), PdfString::text(&t.text));
|
||||
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
|
||||
d.set(b"Size".to_vec(), Object::Real(t.size));
|
||||
d.set(b"Color".to_vec(), arr(&t.color));
|
||||
d.set(
|
||||
b"Align".to_vec(),
|
||||
Object::Int(match t.align {
|
||||
Align::Left => 0,
|
||||
Align::Center => 1,
|
||||
Align::Right => 2,
|
||||
Align::Justify => 3,
|
||||
}),
|
||||
);
|
||||
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
|
||||
if t.char_spacing != 0.0 {
|
||||
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
|
||||
}
|
||||
}
|
||||
Content::Image(i) => {
|
||||
d.set(b"Kind".to_vec(), Object::name("Image"));
|
||||
d.set(b"Image".to_vec(), Object::Ref(i.image));
|
||||
d.set(b"Rect".to_vec(), arr(&norm(i.rect)));
|
||||
if i.rotation % 4 != 0 {
|
||||
d.set(b"Rotate".to_vec(), Object::Int(i64::from(i.rotation % 4) * 90));
|
||||
}
|
||||
if i.flip_h {
|
||||
d.set(b"FlipH".to_vec(), Object::Bool(true));
|
||||
}
|
||||
if i.flip_v {
|
||||
d.set(b"FlipV".to_vec(), Object::Bool(true));
|
||||
}
|
||||
if i.crop != [0.0; 4] {
|
||||
d.set(b"Crop".to_vec(), arr(&i.crop));
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
use super::types::{AddedImage, AddedText};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn norm(r: [f64; 4]) -> [f64; 4] {
|
||||
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
|
||||
}
|
||||
|
||||
/// The advance of `s` in the item's standard font: its glyphs' widths and the character spacing
|
||||
/// after each of them, as `Tc` moves the pen (ISO 32000-2 §9.3.2).
|
||||
pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 {
|
||||
t.family.width(s, t.size, t.bold, t.italic) + t.char_spacing * s.chars().count() as f64
|
||||
}
|
||||
|
||||
/// The lines of a text item after wrapping to its box width.
|
||||
pub fn lines(t: &AddedText) -> Vec<String> {
|
||||
wrapped(t).into_iter().map(|(line, _)| line).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops.
|
||||
pub(crate) static WRAP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
|
||||
/// does).
|
||||
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
#[cfg(test)]
|
||||
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||||
let mut out = Vec::new();
|
||||
for para in t.text.split('\n') {
|
||||
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
|
||||
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
|
||||
let thai = para.chars().any(is_thai);
|
||||
if thai
|
||||
&& !rtl_script
|
||||
&& let Ok(lines) = thai_lines(t, para, width)
|
||||
{
|
||||
out.extend(lines.into_iter().map(|line| (line, false)));
|
||||
continue;
|
||||
}
|
||||
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||||
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
|
||||
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
|
||||
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
|
||||
// of an em apart.
|
||||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) };
|
||||
let space = measure(" ");
|
||||
let (mut line, mut line_w) = (String::new(), 0.0);
|
||||
for word in para.split(' ') {
|
||||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
let candidate_w =
|
||||
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
if !line.is_empty() && candidate_w > width {
|
||||
out.push((std::mem::take(&mut line), rtl));
|
||||
line = word.to_string();
|
||||
line_w = measure(word);
|
||||
} else {
|
||||
line = candidate;
|
||||
line_w = candidate_w;
|
||||
}
|
||||
}
|
||||
out.push((line, rtl));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base.
|
||||
/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation.
|
||||
fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, EditError> {
|
||||
let (pieces, _) = shaped_layout(t, para, false)?;
|
||||
let mut atoms = Vec::new();
|
||||
for piece in pieces {
|
||||
match piece {
|
||||
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))),
|
||||
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
|
||||
let text = c.to_string();
|
||||
let w = latin_width(t, &text);
|
||||
(text, w)
|
||||
})),
|
||||
}
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
let (mut line, mut used) = (String::new(), 0.0);
|
||||
for (text, advance) in atoms {
|
||||
if !line.is_empty() && used + advance > width {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
used = 0.0;
|
||||
}
|
||||
line.push_str(&text);
|
||||
used += advance;
|
||||
}
|
||||
lines.push(line);
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
pub(super) fn is_thai(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0E00..=0x0E7F)
|
||||
}
|
||||
|
||||
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
|
||||
/// craft-fonts Arabic face.
|
||||
pub(super) fn is_arabic(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
|
||||
}
|
||||
|
||||
/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an
|
||||
/// item with any of them is drawn with the craft-fonts Hebrew face.
|
||||
pub(super) fn is_hebrew(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F)
|
||||
}
|
||||
|
||||
/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn
|
||||
/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't
|
||||
/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor
|
||||
/// keeps it open.
|
||||
pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
if t.text.chars().any(is_thai) {
|
||||
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
|
||||
return Some(c);
|
||||
}
|
||||
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
|
||||
return first_undrawable(&rest);
|
||||
}
|
||||
// Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it.
|
||||
if let Some(c) =
|
||||
t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c)))
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
if !shaped_by_a_face(t) {
|
||||
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
|
||||
// that is also how `draw` redraws an existing item, so it's checked that way.
|
||||
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
|
||||
}
|
||||
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
|
||||
wrapped(t).into_iter().find_map(|(line, rtl)| {
|
||||
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
|
||||
pieces.iter().find_map(|p| match p {
|
||||
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
|
||||
Piece::Shaped(_) => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with
|
||||
/// [`draw_shaped`]).
|
||||
fn shaped_by_a_face(t: &AddedText) -> bool {
|
||||
(pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic))
|
||||
|| (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew))
|
||||
}
|
||||
|
||||
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
|
||||
pub(super) fn shaped_arabic(c: char) -> bool {
|
||||
is_arabic(c) && !is_bidi_control(c)
|
||||
}
|
||||
|
||||
/// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn.
|
||||
fn is_bidi_control(c: char) -> bool {
|
||||
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
|
||||
}
|
||||
|
||||
fn mirrored(c: char) -> char {
|
||||
match c {
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'«' => '»',
|
||||
'»' => '«',
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of a line with shaped text, in drawing order.
|
||||
pub(super) enum Piece {
|
||||
/// Shown in the item's own standard font.
|
||||
Latin(String),
|
||||
Shaped(Vec<ShapedCluster>),
|
||||
}
|
||||
|
||||
/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
|
||||
/// its width at the item's size. `rtl` is its paragraph's direction.
|
||||
pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
|
||||
// unicode-bidi indexes the first level of a line, so an empty one would panic.
|
||||
if line.is_empty() {
|
||||
return Ok((Vec::new(), 0.0));
|
||||
}
|
||||
let base = if rtl { Level::rtl() } else { Level::ltr() };
|
||||
let info = ParagraphBidiInfo::new(line, Some(base));
|
||||
let (levels, runs) = info.visual_runs(0..line.len());
|
||||
let mut pieces = Vec::new();
|
||||
for run in runs {
|
||||
let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl);
|
||||
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
|
||||
let Some(text) = line.get(run) else { continue };
|
||||
// Arabic and Hebrew letters take their face; so do spaces and punctuation between them
|
||||
// (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what
|
||||
// the faces lack) keeps the item's font.
|
||||
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
|
||||
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
|
||||
let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew));
|
||||
let script = if is_thai(c) {
|
||||
Some(ShapedScript::Thai)
|
||||
} else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) {
|
||||
Some(ShapedScript::Hebrew)
|
||||
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
|
||||
Some(ShapedScript::Arabic)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match segments.last_mut() {
|
||||
Some((kind, s)) if *kind == script => s.push(c),
|
||||
_ => segments.push((script, c.to_string())),
|
||||
}
|
||||
}
|
||||
if run_rtl {
|
||||
segments.reverse();
|
||||
}
|
||||
for (script, s) in segments {
|
||||
pieces.push(if let Some(script) = script {
|
||||
let shaped = match script {
|
||||
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
|
||||
ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?,
|
||||
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
|
||||
};
|
||||
Piece::Shaped(shaped)
|
||||
} else if run_rtl {
|
||||
Piece::Latin(s.chars().rev().map(mirrored).collect())
|
||||
} else {
|
||||
Piece::Latin(s)
|
||||
});
|
||||
}
|
||||
}
|
||||
let width = pieces
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Piece::Latin(s) => latin_width(t, s),
|
||||
// Each cluster is shown on its own, so the spacing follows each once.
|
||||
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::<f64>(),
|
||||
})
|
||||
.sum();
|
||||
Ok((pieces, width))
|
||||
}
|
||||
|
||||
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
|
||||
/// then reports why).
|
||||
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
|
||||
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s))
|
||||
}
|
||||
|
||||
pub(super) fn arabic_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => "Arabic text needs PdfCraft's Arabic font, which this build doesn't include \
|
||||
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has an Arab face)"
|
||||
.into(),
|
||||
GlyphError::Missing => format!("the Arabic font can't show \"{text}\""),
|
||||
GlyphError::TooComplex => format!("the Arabic font's glyphs for \"{text}\" are too complex"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \
|
||||
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)"
|
||||
.into(),
|
||||
GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""),
|
||||
GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn thai_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => {
|
||||
"Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into()
|
||||
}
|
||||
GlyphError::Missing => format!("the Thai font can't show \"{text}\""),
|
||||
GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The box a text item occupies (height from its lines).
|
||||
pub fn text_rect(t: &AddedText) -> [f64; 4] {
|
||||
let r = norm(t.rect);
|
||||
let h = lines(t).len().max(1) as f64 * t.size * 1.2;
|
||||
[r[0], r[3] - h, r[2], r[3]]
|
||||
}
|
||||
|
||||
/// How far each paste onto a page already holding the copy is offset, down and to the right, so a
|
||||
/// copy doesn't hide what it was copied from (points).
|
||||
pub const PASTE_STEP: f64 = 18.0;
|
||||
|
||||
/// Where a pasted item of box `rect` goes on a page of `size` (display space): with its top-left
|
||||
/// corner at `at`, else where it was, `steps` paste steps ([`PASTE_STEP`]) down and to the right.
|
||||
/// It is kept on the page; one larger than the page keeps its left and top edges on it. A page
|
||||
/// or point that isn't a number leaves the box where it was.
|
||||
pub fn paste_rect(rect: [f64; 4], size: [f64; 2], steps: u32, at: Option<[f64; 2]>) -> [f64; 4] {
|
||||
let [x0, y0, x1, y1] = norm(rect);
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let (left, top) = match at {
|
||||
Some([x, y]) => (x, y),
|
||||
None => {
|
||||
let d = PASTE_STEP * f64::from(steps);
|
||||
(x0 + d, y1 - d)
|
||||
}
|
||||
};
|
||||
if !size.iter().chain(&[left, top, w, h]).all(|v| v.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let left = left.min(size[0] - w).max(0.0);
|
||||
let top = top.max(h).min(size[1]);
|
||||
[left, top - h, left + w, top]
|
||||
}
|
||||
|
||||
/// Most paste steps [`paste_place`] tries before giving up on finding a free place.
|
||||
const MAX_PASTE_STEPS: u32 = 64;
|
||||
|
||||
/// Where Edit ▸ Paste puts `content` on a page of `size` already holding `items` (display space):
|
||||
/// where it was, or as many paste steps down and to the right as it takes not to land exactly on
|
||||
/// a copy of it (the original it was copied from, or an earlier paste).
|
||||
pub fn paste_place<'a>(items: impl IntoIterator<Item = &'a Content>, content: &Content, size: [f64; 2]) -> [f64; 4] {
|
||||
let same = copy_key(content);
|
||||
let copies: Vec<[f64; 4]> = items.into_iter().filter(|i| copy_key(i) == same).map(Content::rect).collect();
|
||||
let taken = |rect: [f64; 4]| copies.iter().any(|c| c.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5));
|
||||
let mut rect = paste_rect(content.rect(), size, 0, None);
|
||||
for steps in 1..=MAX_PASTE_STEPS {
|
||||
if !taken(rect) {
|
||||
break;
|
||||
}
|
||||
rect = paste_rect(content.rect(), size, steps, None);
|
||||
}
|
||||
rect
|
||||
}
|
||||
|
||||
/// What makes two items copies of each other for [`paste_place`]: all but the box, and for an
|
||||
/// image all but the object holding its picture, which a paste into another document stores anew.
|
||||
fn copy_key(c: &Content) -> Content {
|
||||
match c.with_rect([0.0; 4]) {
|
||||
Content::Image(img) => Content::Image(AddedImage { image: ObjRef::new(0, 0), ..img }),
|
||||
text => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
|
||||
pub fn text_width(t: &AddedText) -> f64 {
|
||||
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use super::draw::{draw, params};
|
||||
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
|
||||
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
|
||||
use super::*;
|
||||
|
||||
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
let r = nums(doc, d.get(b"Rect"));
|
||||
let rect: [f64; 4] = r.try_into().ok()?;
|
||||
match d.name(b"Kind")? {
|
||||
b"Text" => {
|
||||
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
|
||||
let c = nums(doc, d.get(b"Color"));
|
||||
Some(Content::Text(AddedText {
|
||||
rect,
|
||||
text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
|
||||
family,
|
||||
bold,
|
||||
italic,
|
||||
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
|
||||
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
|
||||
align: match d.int(b"Align") {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
_ => Align::Left,
|
||||
},
|
||||
// Items written before character spacing have none.
|
||||
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
}))
|
||||
}
|
||||
b"Image" => {
|
||||
let crop = nums(doc, d.get(b"Crop"));
|
||||
Some(Content::Image(AddedImage {
|
||||
rect,
|
||||
image: d.get(b"Image")?.as_ref()?,
|
||||
rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8,
|
||||
flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))),
|
||||
flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))),
|
||||
crop: crop.try_into().unwrap_or([0.0; 4]),
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_resolved<T>(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T {
|
||||
match object {
|
||||
Object::Ref(reference) => {
|
||||
let resolved = doc.get(*reference);
|
||||
f(&resolved)
|
||||
}
|
||||
direct => f(direct),
|
||||
}
|
||||
}
|
||||
|
||||
fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) {
|
||||
let Some(contents) = page.dict.get(b"Contents") else { return };
|
||||
with_resolved(doc, contents, |resolved| match resolved {
|
||||
Object::Array(entries) => {
|
||||
for entry in entries {
|
||||
visit(entry);
|
||||
}
|
||||
}
|
||||
_ => visit(contents),
|
||||
});
|
||||
}
|
||||
|
||||
/// Finds page marks and editable added content in one pass over the page content entries.
|
||||
pub fn scan_markers(doc: &Document) -> MarkerScan {
|
||||
let mut scan = MarkerScan::default();
|
||||
for (pi, page) in page_list(doc).iter().enumerate() {
|
||||
for_each_content_entry(doc, page, |entry| {
|
||||
with_resolved(doc, entry, |resolved| {
|
||||
let Some(dict) = resolved.as_dict() else { return };
|
||||
let tag = dict.name(b"PCMark");
|
||||
let kind = if tag == Some(&b"HeaderFooter"[..]) {
|
||||
Some(MarkKind::HeaderFooter)
|
||||
} else if tag == Some(&b"Watermark"[..]) {
|
||||
Some(MarkKind::Watermark)
|
||||
} else if tag == Some(&b"Background"[..]) {
|
||||
Some(MarkKind::Background)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(kind) = kind
|
||||
&& !scan.marks.contains(&kind)
|
||||
{
|
||||
scan.marks.push(kind);
|
||||
}
|
||||
|
||||
let Some(obj) = Object::as_ref(entry) else { return };
|
||||
if tag != Some(TAG.as_bytes()) {
|
||||
return;
|
||||
}
|
||||
let Some(params_object) = dict.get(b"PCAdded") else { return };
|
||||
with_resolved(doc, params_object, |resolved_params| {
|
||||
let Some(params) = resolved_params.as_dict() else { return };
|
||||
if let Some(content) = parse(doc, params) {
|
||||
// Display points depend on the page's /UserUnit. An item records the one it was
|
||||
// written for; none means 1, as for items written before display space followed
|
||||
// /UserUnit.
|
||||
let written = params
|
||||
.get(b"UserUnit")
|
||||
.and_then(Object::as_f64)
|
||||
.filter(|u| u.is_finite() && *u >= 1.0)
|
||||
.map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT));
|
||||
scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
scan
|
||||
}
|
||||
|
||||
fn validate(c: &Content) -> Result<(), EditError> {
|
||||
let r = c.rect();
|
||||
if !r.iter().all(|v| v.is_finite()) {
|
||||
return Err(EditError::Invalid("invalid position".into()));
|
||||
}
|
||||
match c {
|
||||
Content::Text(t) => {
|
||||
if t.text.trim().is_empty() {
|
||||
return Err(EditError::Invalid("type some text first".into()));
|
||||
}
|
||||
if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) {
|
||||
return Err(EditError::Invalid("the font size must be between 1 and 500 points".into()));
|
||||
}
|
||||
if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
|
||||
)));
|
||||
}
|
||||
if (r[2] - r[0]).abs() < 1.0 {
|
||||
return Err(EditError::Invalid("the text box is too narrow".into()));
|
||||
}
|
||||
if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {
|
||||
return Err(thai_error(GlyphError::NoFont, &t.text));
|
||||
}
|
||||
// Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new
|
||||
// item is refused with what's missing, and one that already holds Arabic stays movable.
|
||||
// Everything else in it is drawn in the standard font, so it still has to fit.
|
||||
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
|
||||
t.text.split('\n').find_map(|line| {
|
||||
line.chars()
|
||||
.find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))
|
||||
})
|
||||
} else {
|
||||
first_undrawable(t)
|
||||
};
|
||||
if let Some(c) = undrawable {
|
||||
return Err(crate::undrawable(c));
|
||||
}
|
||||
}
|
||||
Content::Image(_) => {
|
||||
if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 {
|
||||
return Err(EditError::Invalid("the image is too small".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write an item's stream (new, or replacing `obj`) and make its resources available to the page.
|
||||
fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> Result<ObjRef, EditError> {
|
||||
validate(c)?;
|
||||
let all = page_list(doc);
|
||||
check(&[page], all.len())?;
|
||||
let p = &all[page];
|
||||
let view = p.view_matrix(doc);
|
||||
let user_unit = p.user_unit(doc);
|
||||
// The page gets its own copy of its resources with the item's fonts and images added, and
|
||||
// without the Arabic fonts of the version it replaces.
|
||||
let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
if let Some(r) = obj {
|
||||
drop_fonts(doc, &mut pres, &own_fonts(doc, r));
|
||||
}
|
||||
let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;
|
||||
if obj.is_none() {
|
||||
crate::check_on_top(doc, p)?;
|
||||
}
|
||||
for (k, v) in res.iter() {
|
||||
let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
|
||||
for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) {
|
||||
sub.set(n2.clone(), o.clone());
|
||||
}
|
||||
pres.set(k.clone(), Object::Dict(sub));
|
||||
}
|
||||
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?;
|
||||
let mut sd = Dict::new();
|
||||
sd.set(b"PCMark".to_vec(), Object::name(TAG));
|
||||
let mut added = params(c);
|
||||
if user_unit != 1.0 {
|
||||
added.set(b"UserUnit".to_vec(), Object::Real(user_unit));
|
||||
}
|
||||
sd.set(b"PCAdded".to_vec(), Object::Dict(added));
|
||||
let stream = Stream::flate(sd, &content);
|
||||
match obj {
|
||||
Some(r) => {
|
||||
doc.set(r, Object::Stream(stream));
|
||||
Ok(r)
|
||||
}
|
||||
None => {
|
||||
// place_tagged appends a new stream; recover its reference.
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
place_tagged(doc, &p, TAG, content.clone(), false)?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?;
|
||||
doc.set(r, Object::Stream(stream));
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every added item, page by page, in drawing order.
|
||||
pub fn list_added(doc: &Document) -> Vec<Added> {
|
||||
scan_markers(doc).added
|
||||
}
|
||||
|
||||
/// Add an item to a page (0-based). Returns its index among the page's added items.
|
||||
pub fn add_content(doc: &mut Document, page: usize, c: &Content) -> Result<usize, EditError> {
|
||||
write(doc, page, c, None)?;
|
||||
Ok(list_added(doc).iter().filter(|a| a.page == page).count() - 1)
|
||||
}
|
||||
|
||||
fn find(doc: &Document, page: usize, index: usize) -> Result<Added, EditError> {
|
||||
list_added(doc)
|
||||
.into_iter()
|
||||
.filter(|a| a.page == page)
|
||||
.nth(index)
|
||||
.ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page + 1, index + 1)))
|
||||
}
|
||||
|
||||
/// Replace item `index` of `page` (moved, resized, retyped or reformatted).
|
||||
pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content) -> Result<(), EditError> {
|
||||
let a = find(doc, page, index)?;
|
||||
if std::mem::discriminant(&a.content) != std::mem::discriminant(c) {
|
||||
return Err(EditError::Invalid("an item can't change between text and image".into()));
|
||||
}
|
||||
write(doc, page, c, Some(a.obj))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove item `index` of `page`.
|
||||
pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||||
let a = find(doc, page, index)?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
let list: Vec<Object> = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect();
|
||||
let fonts = own_fonts(doc, a.obj);
|
||||
// Only the page's own resources carry an item's fonts (`write` gives the page a copy).
|
||||
let res = match p.dict.get(b"Resources") {
|
||||
Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let res = res.map(|mut r| {
|
||||
drop_fonts(doc, &mut r, &fonts);
|
||||
r
|
||||
});
|
||||
doc.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(list));
|
||||
if let Some(r) = res {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(r));
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leave only item `index` on `page`: its contents become the item's own stream and its
|
||||
/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels
|
||||
/// alone. For previews on a copy of the document; the page's resources stay as they are.
|
||||
pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||||
let a = find(doc, page, index)?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
// An item's stream starts from the page's initial graphics state: `place_tagged` closes
|
||||
// everything drawn before it, so it draws the same on its own.
|
||||
doc.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)]));
|
||||
d.remove(b"Annots");
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
|
||||
/// names them so), so they go when the item is replaced or deleted.
|
||||
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
|
||||
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };
|
||||
let Ok(data) = s.decoded() else { return Vec::new() };
|
||||
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect()
|
||||
}
|
||||
|
||||
fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
|
||||
if names.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return };
|
||||
for name in names {
|
||||
fonts.remove(name);
|
||||
}
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use super::layout::norm;
|
||||
use super::*;
|
||||
|
||||
pub(super) const TAG: &str = "Added";
|
||||
|
||||
/// The most character spacing an added text takes either way, in points: as far as the font size goes.
|
||||
pub const CHAR_SPACING_LIMIT: f64 = 500.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Family {
|
||||
#[default]
|
||||
Helvetica,
|
||||
Times,
|
||||
Courier,
|
||||
}
|
||||
|
||||
impl Family {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Family::Helvetica => "Helvetica",
|
||||
Family::Times => "Times",
|
||||
Family::Courier => "Courier",
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard-14 font name for this family and style.
|
||||
pub fn base_font(self, bold: bool, italic: bool) -> &'static str {
|
||||
match (self, bold, italic) {
|
||||
(Family::Helvetica, false, false) => "Helvetica",
|
||||
(Family::Helvetica, true, false) => "Helvetica-Bold",
|
||||
(Family::Helvetica, false, true) => "Helvetica-Oblique",
|
||||
(Family::Helvetica, true, true) => "Helvetica-BoldOblique",
|
||||
(Family::Times, false, false) => "Times-Roman",
|
||||
(Family::Times, true, false) => "Times-Bold",
|
||||
(Family::Times, false, true) => "Times-Italic",
|
||||
(Family::Times, true, true) => "Times-BoldItalic",
|
||||
(Family::Courier, false, false) => "Courier",
|
||||
(Family::Courier, true, false) => "Courier-Bold",
|
||||
(Family::Courier, false, true) => "Courier-Oblique",
|
||||
(Family::Courier, true, true) => "Courier-BoldOblique",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_base(base: &[u8]) -> (Family, bool, bool) {
|
||||
let s = String::from_utf8_lossy(base);
|
||||
let family = if s.starts_with("Times") {
|
||||
Family::Times
|
||||
} else if s.starts_with("Courier") {
|
||||
Family::Courier
|
||||
} else {
|
||||
Family::Helvetica
|
||||
};
|
||||
(family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique"))
|
||||
}
|
||||
|
||||
/// Advance width of `s` at `size` points, from the published standard-14 metrics.
|
||||
///
|
||||
/// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]),
|
||||
/// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722).
|
||||
pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 {
|
||||
self.std14(bold, italic).text_width(s, size)
|
||||
}
|
||||
|
||||
/// The standard-14 face this family means at the given style.
|
||||
pub fn std14(self, bold: bool, italic: bool) -> Std14 {
|
||||
match (self, bold, italic) {
|
||||
(Family::Courier, false, false) => Std14::Courier,
|
||||
(Family::Courier, true, false) => Std14::CourierBold,
|
||||
(Family::Courier, false, true) => Std14::CourierOblique,
|
||||
(Family::Courier, true, true) => Std14::CourierBoldOblique,
|
||||
(Family::Times, false, false) => Std14::TimesRoman,
|
||||
(Family::Times, true, false) => Std14::TimesBold,
|
||||
(Family::Times, false, true) => Std14::TimesItalic,
|
||||
(Family::Times, true, true) => Std14::TimesBoldItalic,
|
||||
(Family::Helvetica, false, false) => Std14::Helvetica,
|
||||
(Family::Helvetica, true, false) => Std14::HelveticaBold,
|
||||
(Family::Helvetica, false, true) => Std14::HelveticaOblique,
|
||||
(Family::Helvetica, true, true) => Std14::HelveticaBoldOblique,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// Lines (but the last) stretched to the box's width.
|
||||
Justify,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AddedText {
|
||||
/// The text box in display space; its width wraps the text and its top is the first line's
|
||||
/// top. The height follows the text.
|
||||
pub rect: [f64; 4],
|
||||
pub text: String,
|
||||
pub family: Family,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub size: f64,
|
||||
pub color: [f64; 3],
|
||||
pub align: Align,
|
||||
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
|
||||
pub char_spacing: f64,
|
||||
}
|
||||
|
||||
impl Default for AddedText {
|
||||
fn default() -> Self {
|
||||
AddedText {
|
||||
rect: [0.0; 4],
|
||||
text: String::new(),
|
||||
family: Family::Helvetica,
|
||||
bold: false,
|
||||
italic: false,
|
||||
size: 12.0,
|
||||
color: [0.0; 3],
|
||||
align: Align::Left,
|
||||
char_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AddedImage {
|
||||
/// Display space.
|
||||
pub rect: [f64; 4],
|
||||
/// The image XObject.
|
||||
pub image: ObjRef,
|
||||
/// Quarter turns counter-clockwise (0–3).
|
||||
pub rotation: u8,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
/// The fraction trimmed from each side of the image: left, bottom, right, top (see
|
||||
/// [`AddedImage::crop_valid`]).
|
||||
pub crop: [f64; 4],
|
||||
}
|
||||
|
||||
impl AddedImage {
|
||||
pub fn new(rect: [f64; 4], image: ObjRef) -> Self {
|
||||
AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] }
|
||||
}
|
||||
|
||||
/// The least of the image's width and of its height that a crop keeps.
|
||||
pub const MIN_CROP_KEEP: f64 = 0.05;
|
||||
|
||||
/// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is
|
||||
/// `0.9500000000000001` in floating point and still keeps 5 %.
|
||||
const CROP_ROUNDING: f64 = 1e-9;
|
||||
|
||||
/// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least
|
||||
/// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite
|
||||
/// trims nothing.
|
||||
pub fn crop_valid(crop: [f64; 4]) -> bool {
|
||||
let [l, b, r, t] = crop;
|
||||
let most = 1.0 - Self::MIN_CROP_KEEP;
|
||||
crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING
|
||||
}
|
||||
|
||||
/// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side
|
||||
/// in range, and an axis that trims too much keeps its two sides' proportion.
|
||||
pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] {
|
||||
let most = 1.0 - Self::MIN_CROP_KEEP;
|
||||
let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
|
||||
// Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion;
|
||||
// a pair within the limit keeps each side within it too.
|
||||
let fit = |a: f64, c: f64| {
|
||||
let half = a / 2.0 + c / 2.0;
|
||||
if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) }
|
||||
};
|
||||
let ((l, r), (b, t)) = (fit(l, r), fit(b, t));
|
||||
[l, b, r, t]
|
||||
}
|
||||
|
||||
/// The matrix from image space (the unit square) to display space: crop, flip, rotate,
|
||||
/// then fill the box.
|
||||
pub(super) fn matrix(&self) -> pdfcraft_content::Matrix {
|
||||
use pdfcraft_content::Matrix as M;
|
||||
let [l, b, r, t] = Self::clamp_crop(self.crop);
|
||||
let (cw, ch) = (1.0 - l - r, 1.0 - b - t);
|
||||
let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]);
|
||||
if self.flip_h {
|
||||
m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0]));
|
||||
}
|
||||
if self.flip_v {
|
||||
m = m.then(&M([1.0, 0.0, 0.0, -1.0, 0.0, 1.0]));
|
||||
}
|
||||
for _ in 0..self.rotation % 4 {
|
||||
// A quarter turn counter-clockwise within the unit square.
|
||||
m = m.then(&M([0.0, 1.0, -1.0, 0.0, 1.0, 0.0]));
|
||||
}
|
||||
let [x0, y0, x1, y1] = norm(self.rect);
|
||||
m.then(&M([x1 - x0, 0.0, 0.0, y1 - y0, x0, y0]))
|
||||
}
|
||||
}
|
||||
|
||||
/// An added item on a page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Added {
|
||||
pub page: usize,
|
||||
/// The item's content stream.
|
||||
pub obj: ObjRef,
|
||||
pub content: Content,
|
||||
}
|
||||
|
||||
/// PdfCraft-owned page content discovered in one page/content traversal.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MarkerScan {
|
||||
pub marks: Vec<MarkKind>,
|
||||
pub added: Vec<Added>,
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use super::marks::{
|
||||
Background, Context, HeaderFooter, MarkKind, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, shrink_matrix,
|
||||
};
|
||||
use super::place::{
|
||||
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
|
||||
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
|
||||
/// those pages are removed first (Acrobat's Replace Existing).
|
||||
pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
if hf.text.iter().all(|t| t.trim().is_empty()) {
|
||||
return Err(EditError::Invalid("type the header or footer text first".into()));
|
||||
}
|
||||
if let Some(c) = hf.first_undrawable() {
|
||||
return Err(undrawable(c));
|
||||
}
|
||||
if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) {
|
||||
return Err(EditError::Invalid("invalid font size or margins".into()));
|
||||
}
|
||||
// Page numbers count up from start_number, so the last selected page needs the most room.
|
||||
// Refuse the whole run before drawing anything: headers applied to some pages and then a
|
||||
// failed page number would leave the document half changed.
|
||||
let last_number = u64::from(hf.start_number)
|
||||
.checked_add(pages.len().saturating_sub(1) as u64)
|
||||
.ok_or_else(|| EditError::Invalid("the header page numbers are too large".into()))?;
|
||||
if last_number > u64::from(u32::MAX) {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the header start number {} leaves no room for {} pages (page numbers stop at {})",
|
||||
hf.start_number,
|
||||
pages.len(),
|
||||
u32::MAX
|
||||
)));
|
||||
}
|
||||
// The same goes for Bates numbers: the last selected page must still fit in a u64.
|
||||
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
|
||||
if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})",
|
||||
pages.len(),
|
||||
u64::MAX
|
||||
)));
|
||||
}
|
||||
// Shrinking needs room on every page: refuse before anything is drawn or replaced.
|
||||
let shrinks = pages
|
||||
.iter()
|
||||
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for &i in pages {
|
||||
check_on_top(doc, &all[i])?;
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
|
||||
}
|
||||
let all = page_list(doc);
|
||||
let count = all.len();
|
||||
for (k, &i) in pages.iter().enumerate() {
|
||||
let page = &all[i];
|
||||
let (w, h) = page.display_size(doc);
|
||||
let number =
|
||||
u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?;
|
||||
let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?;
|
||||
let size = hf.font_size;
|
||||
let [top, bottom, left, right] = hf.margins;
|
||||
// The page resource the face is added under: the usual name, unless the page already
|
||||
// draws another font by it, which must not change under the header.
|
||||
let preferred = match hf.font {
|
||||
added::Family::Helvetica => "PCHelv",
|
||||
added::Family::Times => "PCTimes",
|
||||
added::Family::Courier => "PCCour",
|
||||
};
|
||||
let base = hf.font.base_font(false, false);
|
||||
let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base);
|
||||
let mut body: Vec<u8> = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
|
||||
let mut underlines = String::new();
|
||||
for (slot, template) in hf.text.iter().enumerate() {
|
||||
if template.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lines: Vec<String> = expand(template, number, count, bates, cx).lines().map(str::to_owned).collect();
|
||||
let header = slot < 3;
|
||||
let line_h = size * 1.2;
|
||||
for (li, line) in lines.iter().enumerate() {
|
||||
// Centred and right-aligned text is placed by its width in the chosen face.
|
||||
let tw = hf.font.width(line, size, false, false);
|
||||
let x = match slot % 3 {
|
||||
0 => left,
|
||||
1 => (w - tw) / 2.0,
|
||||
_ => w - right - tw,
|
||||
};
|
||||
// Headers hang below the top margin; footers sit on the bottom margin.
|
||||
let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h };
|
||||
body.extend(text_op(x, y, line));
|
||||
if hf.underline {
|
||||
underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4))));
|
||||
}
|
||||
}
|
||||
}
|
||||
body.extend_from_slice(b"ET\n");
|
||||
if !underlines.is_empty() {
|
||||
body.extend(format!("{}\n{underlines}", rgb(hf.color)).bytes());
|
||||
}
|
||||
let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes();
|
||||
content.extend(body);
|
||||
content.extend_from_slice(END.as_bytes());
|
||||
// A name already there is that very font (see `free_font_name`): left as it is.
|
||||
if !page_fonts(doc, page).contains(resource.as_bytes()) {
|
||||
add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?;
|
||||
}
|
||||
let page = &page_list(doc)[i];
|
||||
place(doc, page, MarkKind::HeaderFooter, content, false)?;
|
||||
if let Some(Some(m)) = shrinks.get(k) {
|
||||
let cm = format!("{} cm\n", m.map(matrix_number).join(" "));
|
||||
let page = &page_list(doc)[i];
|
||||
place_inside_wrapper(doc, page, MarkKind::HeaderFooter.tag(), cm.into_bytes())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a text watermark to `pages`.
|
||||
pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
let text = wm.text.trim();
|
||||
if text.is_empty() && wm.source.is_none() {
|
||||
return Err(EditError::Invalid("type the watermark text first".into()));
|
||||
}
|
||||
if let Some(c) = wm.first_undrawable() {
|
||||
return Err(undrawable(c));
|
||||
}
|
||||
if !(wm.opacity.is_finite()
|
||||
&& wm.rotation.is_finite()
|
||||
&& wm.font_size.is_finite()
|
||||
&& wm.font_size >= 0.0
|
||||
&& wm.offset.iter().all(|o| o.is_finite()))
|
||||
{
|
||||
return Err(EditError::Invalid("invalid watermark settings".into()));
|
||||
}
|
||||
if !wm.behind {
|
||||
for &i in pages {
|
||||
check_on_top(doc, &all[i])?;
|
||||
}
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::Watermark)?;
|
||||
}
|
||||
let opacity = wm.opacity.clamp(0.0, 1.0);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
for &i in pages {
|
||||
let page = page_list(doc)[i].clone();
|
||||
let (w, h) = page.display_size(doc);
|
||||
if let Some(src) = &wm.source {
|
||||
let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc));
|
||||
content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64));
|
||||
content.push_str(&picture(src, (w, h), wm.scale, wm.rotation, wm.offset));
|
||||
content.push_str(END);
|
||||
add_resources(doc, &page, Some(opacity), None)?;
|
||||
add_picture_resource(doc, &page_list(doc)[i].clone(), src)?;
|
||||
let page = &page_list(doc)[i];
|
||||
place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?;
|
||||
continue;
|
||||
}
|
||||
let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01);
|
||||
let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) };
|
||||
let (s, c) = wm.rotation.to_radians().sin_cos();
|
||||
let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]);
|
||||
let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)).into_bytes();
|
||||
content.extend(
|
||||
format!(
|
||||
"/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/PCHelv {} Tf\n{}\n",
|
||||
(opacity * 100.0).round() as i64,
|
||||
n(c),
|
||||
n(s),
|
||||
n(-s),
|
||||
n(c),
|
||||
n(cx),
|
||||
n(cy),
|
||||
n(size),
|
||||
rgb(wm.color)
|
||||
)
|
||||
.bytes(),
|
||||
);
|
||||
let line_h = size * 1.15;
|
||||
let total = line_h * (lines.len() as f64 - 1.0);
|
||||
for (li, line) in lines.iter().enumerate() {
|
||||
let tw = helvetica_width(line, size);
|
||||
content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line));
|
||||
}
|
||||
content.extend_from_slice(b"ET\n");
|
||||
content.extend_from_slice(END.as_bytes());
|
||||
add_resources(doc, &page, Some(opacity), None)?;
|
||||
let page = &page_list(doc)[i];
|
||||
place(doc, page, MarkKind::Watermark, content, wm.behind)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a solid-colour background behind the content of `pages`.
|
||||
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::Background)?;
|
||||
}
|
||||
let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 };
|
||||
for &i in pages {
|
||||
let page = page_list(doc)[i].clone();
|
||||
let (w, h) = page.display_size(doc);
|
||||
let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc));
|
||||
match &bg.source {
|
||||
Some(src) => {
|
||||
content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64));
|
||||
content.push_str(&picture(src, (w, h), bg.scale, 0.0, [0.0; 2]));
|
||||
}
|
||||
None => content.push_str(&format!("/PCGS{} gs\n{}\n0 0 {} {} re f\n", (opacity * 100.0).round() as i64, rgb(bg.color), n(w), n(h))),
|
||||
}
|
||||
content.push_str(END);
|
||||
add_resources(doc, &page, Some(opacity), None)?;
|
||||
if let Some(src) = &bg.source {
|
||||
add_picture_resource(doc, &page_list(doc)[i].clone(), src)?;
|
||||
}
|
||||
let page = &page_list(doc)[i];
|
||||
place(doc, page, MarkKind::Background, content.into_bytes(), true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
|
||||
/// nothing after them go too, restoring the original content list; a wrapper that content
|
||||
/// after it still relies on (another tool's appended stream) stays.
|
||||
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
let mut removed = 0;
|
||||
for &i in pages {
|
||||
let page = &all[i];
|
||||
let list = contents(doc, page)?;
|
||||
let before = list.len();
|
||||
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
||||
removed += before - kept.len();
|
||||
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
|
||||
// it): nothing comes after them to need the state they restore. Pairs come from the
|
||||
// operators; for content that can't be read that precisely, from the wrapper streams
|
||||
// alone, so updating a mark there doesn't nest another wrapper each time.
|
||||
if before != kept.len() {
|
||||
let wrappers = match isolation(doc, &kept) {
|
||||
Isolation::Clean { wrappers, .. } => wrappers,
|
||||
Isolation::Unknown { .. } | Isolation::TooDeep => {
|
||||
let mut opens = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
for (i, o) in kept.iter().enumerate() {
|
||||
match wrapper_role(doc, o) {
|
||||
Some(true) => opens.push(i),
|
||||
Some(false) => pairs.extend(opens.pop().map(|open| (open, i))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
};
|
||||
let pairs: std::collections::HashMap<usize, usize> = wrappers.into_iter().map(|(open, close)| (close, open)).collect();
|
||||
let mut discarded = std::collections::HashSet::new();
|
||||
let mut end = kept.len();
|
||||
while let Some(close) = end.checked_sub(1) {
|
||||
if discarded.contains(&close) {
|
||||
end = close;
|
||||
} else if let Some(&open) = pairs.get(&close) {
|
||||
discarded.insert(open);
|
||||
discarded.insert(close);
|
||||
end = close;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect();
|
||||
}
|
||||
if before != kept.len() {
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Which kinds of marks the document has (for enabling Update/Remove).
|
||||
pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
|
||||
added::scan_markers(doc).marks
|
||||
}
|
||||
@@ -539,6 +539,11 @@ pub(crate) fn selectable_images(
|
||||
/// Find the saved graphics-state scope containing just this image and its crop. Applying
|
||||
/// the transform before that scope moves the actual clipping path as well as the pixels.
|
||||
/// A shared clip must never be moved along with unrelated artwork or text.
|
||||
///
|
||||
/// Under a clip set outside every scope that holds only this image (a report or table drawing
|
||||
/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active
|
||||
/// clip. The outermost scope holding only the image is used then: it contains all of the
|
||||
/// image's private clips, which move with it, while the outer clip is shared and stays.
|
||||
fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
|
||||
struct Scope {
|
||||
start: usize,
|
||||
@@ -556,6 +561,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
let mut text = false;
|
||||
let mut at_image = None;
|
||||
let mut isolated = None;
|
||||
let mut outermost = None;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
@@ -566,9 +572,13 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some(scope) = stack.pop() {
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
|
||||
// Prefer the innermost scope that contains every active clipping path.
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_path {
|
||||
if !scope.inherited_clip {
|
||||
// Prefer the innermost scope that contains every active clipping path.
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
}
|
||||
// Scopes close inner first, so the last one recorded is the outermost.
|
||||
outermost = Some((scope.start, index, scope.matrix));
|
||||
}
|
||||
matrix = scope.matrix;
|
||||
clipped = scope.inherited_clip;
|
||||
@@ -611,7 +621,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
if !clipped {
|
||||
return Ok(Some((target, target, placement)));
|
||||
}
|
||||
Ok(isolated)
|
||||
Ok(isolated.or(outermost))
|
||||
}
|
||||
|
||||
fn transform_image(
|
||||
|
||||
+17
-988
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,298 @@
|
||||
use super::*;
|
||||
|
||||
/// The kinds of page marks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MarkKind {
|
||||
HeaderFooter,
|
||||
Watermark,
|
||||
Background,
|
||||
}
|
||||
|
||||
impl MarkKind {
|
||||
pub(crate) fn tag(self) -> &'static str {
|
||||
match self {
|
||||
MarkKind::HeaderFooter => "HeaderFooter",
|
||||
MarkKind::Watermark => "Watermark",
|
||||
MarkKind::Background => "Background",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header and footer settings (Acrobat's Add Header and Footer dialog).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HeaderFooter {
|
||||
/// Left, centre and right header text, then left, centre and right footer text. Tokens:
|
||||
/// `<<1>>` page number, `<<n>>` page count, `<<1 of n>>`, `<<1/n>>`, `<<Page 1>>`,
|
||||
/// `<<Page 1 of n>>`, dates `<<m/d/yyyy>>`, `<<mm/dd/yyyy>>`, `<<d/m/yyyy>>`, `<<yyyy-mm-dd>>`,
|
||||
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, the dotted day-first
|
||||
/// dates of German-speaking and much of Central and Eastern Europe `<<dd.mm.yyyy>>`,
|
||||
/// `<<d.m.yyyy>>`, `<<dd.mm.yy>>`, `<<d.m.yy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||||
/// (digits, start, prefix, suffix).
|
||||
pub text: [String; 6],
|
||||
/// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier.
|
||||
pub font: added::Family,
|
||||
pub font_size: f64,
|
||||
pub color: Rgb,
|
||||
pub underline: bool,
|
||||
/// Margins in points: top, bottom, left, right (the text's distance from the page edges).
|
||||
pub margins: [f64; 4],
|
||||
/// The number `<<1>>` shows on the first page of the range.
|
||||
pub start_number: u32,
|
||||
/// Shrink the page content so the header and footer don't cover it (Acrobat's Appearance
|
||||
/// Options): the original content is scaled down evenly into the band the header and footer
|
||||
/// leave free, centred across the page. Remove and Update take the scaling away again.
|
||||
pub shrink: bool,
|
||||
}
|
||||
|
||||
impl Default for HeaderFooter {
|
||||
/// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: Default::default(),
|
||||
font: added::Family::Helvetica,
|
||||
font_size: 8.0,
|
||||
color: [0.0; 3],
|
||||
underline: false,
|
||||
margins: [36.0, 36.0, 72.0, 72.0],
|
||||
start_number: 1,
|
||||
shrink: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HeaderFooter {
|
||||
/// The first character [`add_header_footer`] would refuse, because the standard fonts can't
|
||||
/// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the
|
||||
/// template), so checking the templates, split as `expand`'s output is, covers every page
|
||||
/// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview
|
||||
/// never offers text that applying refuses (#2013).
|
||||
pub fn first_undrawable(&self) -> Option<char> {
|
||||
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
|
||||
}
|
||||
|
||||
/// The room the header and the footer take at the top and the bottom of a page, in points:
|
||||
/// the margin, the lines of the tallest box, and a clearance of half the font size. A side
|
||||
/// without text takes none. This is the room [`HeaderFooter::shrink`] keeps free.
|
||||
pub fn bands(&self) -> (f64, f64) {
|
||||
let size = self.font_size;
|
||||
let line_h = size * 1.2;
|
||||
let lines = |boxes: &[String]| boxes.iter().filter(|t| !t.trim().is_empty()).map(|t| t.lines().count().max(1)).max();
|
||||
let (header, footer) = self.text.split_at(3);
|
||||
// Header lines hang below the top margin (a full line each); the footer's last line sits
|
||||
// on the bottom margin, so only its capitals (0.8 of the size) rise above it.
|
||||
let top = lines(header).map_or(0.0, |n| self.margins[0] + size + (n - 1) as f64 * line_h + size / 2.0);
|
||||
let bottom = lines(footer).map_or(0.0, |n| self.margins[1] + size * 0.8 + (n - 1) as f64 * line_h + size / 2.0);
|
||||
(top, bottom)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `cm` that shrinks a page's original content (user space) into the band `hf` leaves free,
|
||||
/// for a page shown `size` (display space) with view matrix `view` (display to user space): an
|
||||
/// even scale, centred across, the content's bottom on the footer's band.
|
||||
pub(crate) fn shrink_matrix(hf: &HeaderFooter, size: (f64, f64), view: [f64; 6]) -> Result<[f64; 6], EditError> {
|
||||
let (w, h) = size;
|
||||
let (top, bottom) = hf.bands();
|
||||
let free = h - top - bottom;
|
||||
if !(free.is_finite() && free >= 1.0 && w.is_finite() && w > 0.0) {
|
||||
return Err(EditError::Invalid("the header and footer margins leave no room for the page content to shrink into".into()));
|
||||
}
|
||||
let k = free / h;
|
||||
let v = kurbo::Affine::new(view);
|
||||
let det = v.determinant();
|
||||
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||
return Err(EditError::Invalid("the page can't be shrunk: its view matrix is degenerate".into()));
|
||||
}
|
||||
let shrink = kurbo::Affine::new([k, 0.0, 0.0, k, (w - w * k) / 2.0, bottom]);
|
||||
// User space to display space, shrink there, and back.
|
||||
Ok((v * shrink * v.inverse()).as_coeffs())
|
||||
}
|
||||
|
||||
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
|
||||
/// the document, an image (drawn into a unit square) or a form (a page of a PDF).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MarkSource {
|
||||
pub xobject: pdfcraft_cos::ObjRef,
|
||||
/// Its natural size in points.
|
||||
pub size: (f64, f64),
|
||||
/// An image XObject (unit square); otherwise a form whose `/Matrix` maps it to `size`.
|
||||
pub image: bool,
|
||||
}
|
||||
|
||||
/// A watermark: text, or a picture from a file (`source`; the text is then ignored).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Watermark {
|
||||
pub text: String,
|
||||
pub source: Option<MarkSource>,
|
||||
/// Pictures: size relative to the page (1.0 fits the page).
|
||||
pub scale: f64,
|
||||
/// 0 = fit: about half the page diagonal.
|
||||
pub font_size: f64,
|
||||
pub color: Rgb,
|
||||
/// 0–1.
|
||||
pub opacity: f64,
|
||||
/// Degrees counter-clockwise (Acrobat's 45° runs from bottom-left to top-right).
|
||||
pub rotation: f64,
|
||||
/// Draw behind the page content instead of on top.
|
||||
pub behind: bool,
|
||||
/// Offset of the centre from the page centre, in points (right, up).
|
||||
pub offset: [f64; 2],
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The first character of a text watermark [`add_watermark`] would refuse, because the
|
||||
/// standard fonts can't draw it (#125). A picture watermark draws no text.
|
||||
pub fn first_undrawable(&self) -> Option<char> {
|
||||
match self.source {
|
||||
Some(_) => None,
|
||||
None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
source: None,
|
||||
scale: 0.5,
|
||||
font_size: 0.0,
|
||||
color: [0.6, 0.6, 0.6],
|
||||
opacity: 0.5,
|
||||
rotation: 45.0,
|
||||
behind: false,
|
||||
offset: [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A background behind the page content: a solid colour, or a picture from a file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Background {
|
||||
pub color: Rgb,
|
||||
pub opacity: f64,
|
||||
pub source: Option<MarkSource>,
|
||||
/// Pictures: size relative to the page (1.0 fits the page).
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Default for Background {
|
||||
fn default() -> Self {
|
||||
Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawing a picture mark: fitted to the page at `scale`, centred (plus `offset`), turned by
|
||||
/// `rotation` degrees. Returns the content and the resource name it uses.
|
||||
pub(crate) fn picture(src: &MarkSource, page: (f64, f64), scale: f64, rotation: f64, offset: [f64; 2]) -> String {
|
||||
let (w, h) = page;
|
||||
let (sw, sh) = (src.size.0.max(0.01), src.size.1.max(0.01));
|
||||
let k = (w / sw).min(h / sh) * if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let (dw, dh) = (sw * k, sh * k);
|
||||
let (s, c) = rotation.to_radians().sin_cos();
|
||||
let (cx, cy) = (w / 2.0 + offset[0], h / 2.0 + offset[1]);
|
||||
// Centre, rotate, then place the picture's box (unit square for images).
|
||||
let place = if src.image {
|
||||
format!("{} 0 0 {} {} {} cm", n(dw), n(dh), n(-dw / 2.0), n(-dh / 2.0))
|
||||
} else {
|
||||
format!("{} 0 0 {} {} {} cm", n(k), n(k), n(-dw / 2.0), n(-dh / 2.0))
|
||||
};
|
||||
format!("{} {} {} {} {} {} cm\n{place}\n/PCPic{} Do\n", n(c), n(s), n(-s), n(c), n(cx), n(cy), src.xobject.num)
|
||||
}
|
||||
|
||||
/// Register a mark picture in the page's own resources (`/PCPic<num>`).
|
||||
pub(crate) fn add_picture_resource(doc: &mut Document, page: &pdfcraft_model::Page, src: &MarkSource) -> Result<(), EditError> {
|
||||
let p = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut res = p.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
xo.set(format!("PCPic{}", src.xobject.num).into_bytes(), Object::Ref(src.xobject));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Values tokens need that come from outside the document.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Context {
|
||||
/// Today's date: (year, month 1–12, day 1–31).
|
||||
pub date: (i64, u32, u32),
|
||||
}
|
||||
|
||||
// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision.
|
||||
pub(crate) fn matrix_number(value: f64) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Real(value), &mut bytes);
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn n(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
pub(crate) fn rgb(c: Rgb) -> String {
|
||||
let c = c.map(|x| x.clamp(0.0, 1.0));
|
||||
format!("{} {} {} rg", n(c[0]), n(c[1]), n(c[2]))
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
/// Expand `<<…>>` tokens for page `index` (0-based within the range) of `count`.
|
||||
pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &Context) -> String {
|
||||
let (y, m, d) = cx.date;
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find("<<") {
|
||||
out.push_str(&rest[..start]);
|
||||
let after = &rest[start + 2..];
|
||||
let Some(end) = after.find(">>") else {
|
||||
out.push_str(&rest[start..]);
|
||||
return out;
|
||||
};
|
||||
let token = &after[..end];
|
||||
let p = page_number;
|
||||
let value = match token {
|
||||
"1" => p.to_string(),
|
||||
"n" => count.to_string(),
|
||||
"1 of n" => format!("{p} of {count}"),
|
||||
"1/n" => format!("{p}/{count}"),
|
||||
"Page 1" => format!("Page {p}"),
|
||||
"Page 1 of n" => format!("Page {p} of {count}"),
|
||||
"m/d" => format!("{m}/{d}"),
|
||||
"m/d/yy" => format!("{m}/{d}/{:02}", y.rem_euclid(100)),
|
||||
"m/d/yyyy" => format!("{m}/{d}/{y}"),
|
||||
"mm/dd/yy" => format!("{m:02}/{d:02}/{:02}", y.rem_euclid(100)),
|
||||
"mm/dd/yyyy" => format!("{m:02}/{d:02}/{y}"),
|
||||
"d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)),
|
||||
"d/m/yyyy" => format!("{d}/{m}/{y}"),
|
||||
"dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"),
|
||||
"d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)),
|
||||
"d.m.yyyy" => format!("{d}.{m}.{y}"),
|
||||
"dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)),
|
||||
"dd.mm.yyyy" => format!("{d:02}.{m:02}.{y}"),
|
||||
"yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"),
|
||||
"yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"),
|
||||
"yyyy/m/d" => format!("{y}/{m}/{d}"),
|
||||
"mmmm d, yyyy" => format!("{} {d}, {y}", MONTHS[(m.clamp(1, 12) - 1) as usize]),
|
||||
t if t.starts_with("Bates Number") => {
|
||||
// Bates Number#digits#start#prefix#suffix (start is applied by the caller).
|
||||
let parts: Vec<&str> = t.split('#').collect();
|
||||
let digits = parts.get(1).and_then(|d| d.parse::<usize>().ok()).unwrap_or(6).clamp(1, 15);
|
||||
format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&""))
|
||||
}
|
||||
_ => format!("<<{token}>>"),
|
||||
};
|
||||
out.push_str(&value);
|
||||
rest = &after[end + 2..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The Bates start number in a template (`<<Bates Number#digits#start#…>>`), if any.
|
||||
pub(crate) fn bates_start(template: &str) -> Option<u64> {
|
||||
let i = template.find("<<Bates Number")?;
|
||||
let t = &template[i + 2..];
|
||||
let t = &t[..t.find(">>")?];
|
||||
t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1))
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
use super::marks::{MarkKind, matrix_number, n};
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
|
||||
pdfcraft_model::pages(doc)
|
||||
}
|
||||
|
||||
pub(crate) fn check(pages: &[usize], count: usize) -> Result<(), EditError> {
|
||||
match pages.iter().find(|p| **p >= count) {
|
||||
Some(p) => Err(EditError::NoSuchPage(*p)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's `/Contents` as a list of stream references (inline content is promoted).
|
||||
pub(crate) fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<Vec<Object>, EditError> {
|
||||
let obj = doc.get(page.obj);
|
||||
let d = obj.as_dict().cloned().unwrap_or_default();
|
||||
Ok(match d.get(b"Contents").cloned() {
|
||||
None => Vec::new(),
|
||||
Some(Object::Ref(r)) => match doc.get(r).as_array() {
|
||||
Some(a) => a.clone(),
|
||||
None => vec![Object::Ref(r)],
|
||||
},
|
||||
Some(Object::Array(a)) => a,
|
||||
Some(other) => vec![Object::Ref(doc.add(other))],
|
||||
})
|
||||
}
|
||||
|
||||
/// Add the font (and an opacity state) to the page's own resources.
|
||||
pub(crate) fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, content: Option<&[u8]>) -> Result<(), EditError> {
|
||||
let mut faces: Vec<(Vec<u8>, &str)> = Vec::new();
|
||||
for (name, base) in [(&b"PCHelv"[..], "Helvetica"), (b"PCTimes", "Times-Roman"), (b"PCCour", "Courier")] {
|
||||
// Helvetica always (marks use it); the others only when the content names them.
|
||||
if name != b"PCHelv" && !content.is_some_and(|c| c.windows(name.len() + 1).any(|w| w[0] == b'/' && &w[1..] == name)) {
|
||||
continue;
|
||||
}
|
||||
faces.push((name.to_vec(), base));
|
||||
}
|
||||
add_page_resources(doc, page, opacity, &faces)
|
||||
}
|
||||
|
||||
/// The fonts a page draws with: its own resources' or the ones it inherits.
|
||||
pub(crate) fn page_fonts(doc: &Document, page: &pdfcraft_model::Page) -> Dict {
|
||||
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A name in `fonts` for the standard face `base` that can't take over a font the page already
|
||||
/// draws with: `preferred` when it is free or already names exactly the dictionary
|
||||
/// [`add_page_resources`] writes for that face, otherwise the first such `preferred_1`,
|
||||
/// `preferred_2`… (one of the first `fonts.len() + 1` always is). A font with anything more
|
||||
/// (`/ToUnicode`, `/Widths`…) is someone else's, even in the same face.
|
||||
pub(crate) fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base: &str) -> String {
|
||||
let usable = |name: &str| match fonts.get(name.as_bytes()) {
|
||||
None => true,
|
||||
Some(font) => doc.resolve(font).as_dict().is_some_and(|d| {
|
||||
d.len() == 4
|
||||
&& d.name(b"Type") == Some(b"Font")
|
||||
&& d.name(b"Subtype") == Some(b"Type1")
|
||||
&& d.name(b"BaseFont") == Some(base.as_bytes())
|
||||
&& d.name(b"Encoding") == Some(b"WinAnsiEncoding")
|
||||
}),
|
||||
};
|
||||
if usable(preferred) {
|
||||
return preferred.to_string();
|
||||
}
|
||||
(1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string())
|
||||
}
|
||||
|
||||
/// Add the standard `faces` (resource name, base font) and an opacity state to the page's own
|
||||
/// resources.
|
||||
pub(crate) fn add_page_resources(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
opacity: Option<f64>,
|
||||
faces: &[(Vec<u8>, &str)],
|
||||
) -> Result<(), EditError> {
|
||||
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, base) in faces {
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
fonts.set(name.clone(), Object::Dict(font));
|
||||
}
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
if let Some(o) = opacity {
|
||||
let mut gs = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
|
||||
let mut s = Dict::new();
|
||||
s.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
s.set(b"CA".to_vec(), Object::Real(o));
|
||||
s.set(b"ca".to_vec(), Object::Real(o));
|
||||
let name = format!("PCGS{}", (o * 100.0).round() as i64);
|
||||
gs.set(name.into_bytes(), Object::Dict(s));
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// The page gets its own (possibly copied) resources; shared dictionaries stay untouched.
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
|
||||
|
||||
/// Starts with an end of line, so a comment left open at the end of the content before it
|
||||
/// can't swallow the `Q`. Older files have the close without it.
|
||||
pub(crate) const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
|
||||
|
||||
pub(crate) const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
|
||||
/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
|
||||
/// graphics closures. Legacy closes without the leading newline remain valid on their own.
|
||||
pub(crate) fn is_wrap_close(bytes: &[u8]) -> bool {
|
||||
let mut rest = bytes;
|
||||
while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
|
||||
rest = shorter;
|
||||
}
|
||||
let legacy = rest.len() == bytes.len();
|
||||
loop {
|
||||
if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
|
||||
return true;
|
||||
}
|
||||
match rest.strip_prefix(b"\nQ".as_slice()) {
|
||||
Some(shorter) => rest = shorter,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)`
|
||||
/// for a close.
|
||||
pub(crate) fn wrapper_role(doc: &Document, o: &Object) -> Option<bool> {
|
||||
if stream_tag(doc, o).as_deref() != Some("Wrap") {
|
||||
return None;
|
||||
}
|
||||
let Object::Stream(stream) = &*doc.resolve(o) else { return None };
|
||||
// Wrappers are a few bytes; one that decodes to more isn't PdfCraft's.
|
||||
let bytes = stream.decoded_within(64 * 1024).ok()?;
|
||||
if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
|
||||
match &*doc.resolve(o) {
|
||||
Object::Stream(s) => s.decoded().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/PCMark` tag in a content stream's dictionary: a mark kind, or `Wrap` for the q/Q
|
||||
/// wrapper. Read from the dictionary, so finding marks never decompresses page content.
|
||||
pub(crate) fn stream_tag(doc: &Document, o: &Object) -> Option<String> {
|
||||
let obj = doc.resolve(o);
|
||||
obj.as_dict()?.name(b"PCMark").map(|n| String::from_utf8_lossy(n).into_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn tagged(tag: &str) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"PCMark".to_vec(), Object::name(tag));
|
||||
d
|
||||
}
|
||||
|
||||
/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags,
|
||||
/// which another writer may keep, copy or forge).
|
||||
pub(crate) enum Isolation {
|
||||
/// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths.
|
||||
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
|
||||
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
|
||||
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
|
||||
/// `marked`: unfinished marked-content sequences to close before a new mark.
|
||||
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)>, marked: usize },
|
||||
/// The content could not be read that precisely (it doesn't decode, is too large, or has
|
||||
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
|
||||
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
|
||||
/// marked-content sequences independently of graphics malformation, or zero when unknown.
|
||||
Unknown { unclosed: usize, marked: usize },
|
||||
/// Marked content nests past [`ISOLATION_MAX_DEPTH`]: how many sequences a mark on top would
|
||||
/// have to close (some may be hidden optional content) is unknown.
|
||||
TooDeep,
|
||||
}
|
||||
|
||||
const ISOLATION_MAX_STREAMS: usize = 100_000;
|
||||
|
||||
const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
|
||||
const ISOLATION_MAX_OPS: usize = 1_000_000;
|
||||
|
||||
pub(crate) const ISOLATION_MAX_DEPTH: usize = 10_000;
|
||||
|
||||
pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation {
|
||||
let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
|
||||
if list.len() > ISOLATION_MAX_STREAMS {
|
||||
return unknown;
|
||||
}
|
||||
let mut remaining = ISOLATION_MAX_BYTES;
|
||||
let mut data = Vec::with_capacity(list.len());
|
||||
for object in list {
|
||||
let resolved = doc.resolve(object);
|
||||
let Object::Stream(stream) = &*resolved else { return unknown };
|
||||
let Ok(bytes) = stream.decoded_within(remaining) else { return unknown };
|
||||
let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown };
|
||||
remaining = left;
|
||||
data.push(bytes);
|
||||
}
|
||||
let pieces = pdfcraft_content::Pieces::join(&data);
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS {
|
||||
return unknown;
|
||||
}
|
||||
let edges = pieces.comment_edges(&parsed);
|
||||
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let (mut marked, mut marked_known) = (0usize, true);
|
||||
let mut split = 0;
|
||||
let mut previous_last = None;
|
||||
let mut wrappers = Vec::new();
|
||||
for op in &parsed.ops {
|
||||
let (first, last) = pieces.pieces_of(op);
|
||||
// A wrapper can only go in before a whole stream, outside graphics and marked-content
|
||||
// scopes, between operators, where no comment would swallow the wrapper's `q`.
|
||||
if stack.is_empty()
|
||||
&& marked_known
|
||||
&& marked == 0
|
||||
&& !text_open
|
||||
&& !path_open
|
||||
&& !dirty
|
||||
&& previous_last.is_none_or(|previous| first > previous)
|
||||
&& !edges.get(first).is_some_and(|edge| edge.0)
|
||||
{
|
||||
split = first;
|
||||
}
|
||||
previous_last = Some(last);
|
||||
// Match the reader's tolerant operands: BMC uses the last name, BDC the last
|
||||
// name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
|
||||
let begin_marked = match op.op.as_slice() {
|
||||
b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
|
||||
b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
|
||||
_ => false,
|
||||
};
|
||||
if marked_known {
|
||||
if begin_marked {
|
||||
if marked < ISOLATION_MAX_DEPTH {
|
||||
marked += 1;
|
||||
} else {
|
||||
// Once the budget is exceeded, later EMCs cannot restore a known depth.
|
||||
marked_known = false;
|
||||
marked = 0;
|
||||
}
|
||||
} else if op.is("EMC") {
|
||||
marked = marked.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"BT" if !text_open => text_open = true,
|
||||
b"ET" if text_open => text_open = false,
|
||||
b"BT" | b"ET" => malformed = true,
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true,
|
||||
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first),
|
||||
b"Q" if op.operands.is_empty() => match stack.pop() {
|
||||
Some(open) => {
|
||||
// Only a standalone PdfCraft wrapper pair may later be removed again.
|
||||
let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
if wrap(open)
|
||||
&& wrap(first)
|
||||
&& data.get(open).is_some_and(|d| d == WRAP_OPEN)
|
||||
&& data.get(first).is_some_and(|d| is_wrap_close(d))
|
||||
{
|
||||
wrappers.push((open, first));
|
||||
}
|
||||
}
|
||||
None => malformed = true,
|
||||
},
|
||||
b"q" | b"Q" => malformed = true,
|
||||
_ if stack.is_empty() => dirty = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !marked_known {
|
||||
return Isolation::TooDeep;
|
||||
}
|
||||
if malformed || text_open || path_open || !stack.is_empty() {
|
||||
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
|
||||
}
|
||||
let comment_open = edges.last().is_some_and(|edge| edge.1);
|
||||
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
|
||||
}
|
||||
|
||||
/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top
|
||||
/// (appended, after wrapping the original content in q/Q).
|
||||
pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
place_tagged(doc, page, kind.tag(), content, behind).map(drop)
|
||||
}
|
||||
|
||||
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
|
||||
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
|
||||
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
|
||||
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
|
||||
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark; when
|
||||
/// their number is unknown, nothing is placed on top. Returns the new content stream.
|
||||
pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<ObjRef, EditError> {
|
||||
let mut list = contents(doc, page)?;
|
||||
// The behind-marks at the start: backgrounds in the order added, then watermarks.
|
||||
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
|
||||
let behind_marks = list.iter().take_while(|o| stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).count();
|
||||
let wrap = if behind {
|
||||
None
|
||||
} else {
|
||||
match isolation(doc, &list) {
|
||||
Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
|
||||
// Content that can't be read precisely is wrapped whole, as before: after the
|
||||
// behind-marks at the start. A `q` it leaves open is closed at its end, where
|
||||
// closing it changes nothing already drawn.
|
||||
Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
|
||||
// Content left inside an optional-content sequence it cannot close could be hidden.
|
||||
Isolation::TooDeep => return Err(too_deep_on_top()),
|
||||
}
|
||||
};
|
||||
let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content)));
|
||||
let mark = Object::Ref(mark_ref);
|
||||
if behind {
|
||||
// Right after the backgrounds already there, so a new background is painted over the
|
||||
// older ones (#3591) and a watermark over all of them. Not after the watermarks: on a
|
||||
// page with no content, one on top is at the start too and can't be told apart, so a
|
||||
// newer watermark behind goes under the older ones.
|
||||
let at = list.iter().take(behind_marks).rposition(|o| stream_tag(doc, o).as_deref() == Some(MarkKind::Background.tag())).map_or(0, |i| i + 1);
|
||||
list.insert(at, mark);
|
||||
} else {
|
||||
if let Some((split, unclosed, marked)) = wrap {
|
||||
let mut close_bytes = b"\nEMC".repeat(marked);
|
||||
close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
|
||||
close_bytes.extend_from_slice(WRAP_CLOSE);
|
||||
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
|
||||
list.insert(split.min(list.len()), open);
|
||||
list.push(close);
|
||||
}
|
||||
list.push(mark);
|
||||
}
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
|
||||
Ok(mark_ref)
|
||||
}
|
||||
|
||||
fn too_deep_on_top() -> EditError {
|
||||
EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into())
|
||||
}
|
||||
|
||||
/// The refusal [`place_tagged`] gives for a mark on top of `page`, checked before an edit changes
|
||||
/// anything (removing old marks, adding resources) so a refused edit leaves the document as it was.
|
||||
pub(crate) fn check_on_top(doc: &Document, page: &pdfcraft_model::Page) -> Result<(), EditError> {
|
||||
let obj = doc.get(page.obj);
|
||||
let list = match obj.as_dict().and_then(|d| d.get(b"Contents")) {
|
||||
None => return Ok(()),
|
||||
Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_else(|| vec![Object::Ref(*r)]),
|
||||
Some(Object::Array(a)) => a.clone(),
|
||||
Some(other) => vec![other.clone()],
|
||||
};
|
||||
match isolation(doc, &list) {
|
||||
Isolation::TooDeep => Err(too_deep_on_top()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
|
||||
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
|
||||
/// on it and is left alone.
|
||||
pub(crate) fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
|
||||
let mut list = contents(doc, page)?;
|
||||
let Some(open) = list.iter().position(|o| stream_tag(doc, o).as_deref() == Some("Wrap")) else { return Ok(()) };
|
||||
let stream = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
|
||||
list.insert(open + 1, stream);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
|
||||
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
|
||||
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
|
||||
/// Returns the new content stream.
|
||||
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<ObjRef, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(&[page], all.len())?;
|
||||
check_on_top(doc, &all[page])?;
|
||||
add_resources(doc, &all[page], None, Some(&content))?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
place_tagged(doc, &p, tag, content, false)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
|
||||
format!(
|
||||
"q\n/Artifact <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
|
||||
kind.tag(),
|
||||
matrix_number(matrix[0]),
|
||||
matrix_number(matrix[1]),
|
||||
matrix_number(matrix[2]),
|
||||
matrix_number(matrix[3]),
|
||||
matrix_number(matrix[4]),
|
||||
matrix_number(matrix[5])
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) const END: &str = "EMC\nQ\n";
|
||||
|
||||
/// The refusal for page text with `c`, which nothing here can draw.
|
||||
pub(crate) fn undrawable(c: char) -> EditError {
|
||||
EditError::Invalid(undrawable_message(c))
|
||||
}
|
||||
|
||||
/// The wording of the refusal for text with `c`, which the standard fonts can't draw.
|
||||
pub fn undrawable_message(c: char) -> String {
|
||||
format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c))
|
||||
}
|
||||
|
||||
pub(crate) fn text_op(x: f64, y: f64, text: &str) -> Vec<u8> {
|
||||
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
|
||||
v.extend(literal(&win_ansi(text)));
|
||||
v.extend_from_slice(b" Tj\n");
|
||||
v
|
||||
}
|
||||
@@ -75,6 +75,31 @@ fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() {
|
||||
assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_image_crop_keeps_some_of_each_axis() {
|
||||
use crate::added::AddedImage;
|
||||
assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95]));
|
||||
assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5]));
|
||||
// 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is.
|
||||
assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0]));
|
||||
assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]);
|
||||
assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across");
|
||||
assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across");
|
||||
assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0]));
|
||||
assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0]));
|
||||
assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is");
|
||||
// From a file: too much across keeps the two sides' proportion; nonsense trims nothing.
|
||||
let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]);
|
||||
assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0);
|
||||
// Sides beyond the whole image keep their proportion too, and huge ones don't overflow.
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]);
|
||||
assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}");
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]);
|
||||
assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}");
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]);
|
||||
assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_expand() {
|
||||
let c = cx();
|
||||
@@ -82,6 +107,7 @@ fn tokens_expand() {
|
||||
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
|
||||
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
|
||||
assert_eq!(expand("<<yyyy/mm/dd>> <<yyyy/m/d>>", 1, 1, 0, &c), "2026/10/01 2026/10/1");
|
||||
assert_eq!(expand("<<dd.mm.yyyy>> <<d.m.yyyy>> <<dd.mm.yy>> <<d.m.yy>>", 1, 1, 0, &c), "01.10.2026 1.10.2026 01.10.26 1.10.26");
|
||||
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
|
||||
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
|
||||
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
|
||||
@@ -983,6 +1009,28 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
|
||||
assert!(marks_present(&doc).is_empty());
|
||||
}
|
||||
|
||||
/// #4464: a page left with one added item draws that item and nothing else.
|
||||
#[test]
|
||||
fn an_isolated_item_is_all_that_is_left_on_its_page() {
|
||||
let mut doc = fixture();
|
||||
let first = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "First".into(), size: 14.0, ..AddedText::default() };
|
||||
let second = AddedText { rect: [72.0, 400.0, 300.0, 500.0], text: "Second".into(), ..first.clone() };
|
||||
add_content(&mut doc, 0, &Content::Text(first)).unwrap();
|
||||
add_content(&mut doc, 0, &Content::Text(second)).unwrap();
|
||||
let before = streams(&doc, 0).join("\n");
|
||||
assert!(before.contains("(First) Tj") && before.contains("(Second) Tj"), "{before}");
|
||||
assert!(isolate_content(&mut doc, 0, 2).is_err(), "no third item");
|
||||
assert!(isolate_content(&mut doc, 9, 0).is_err(), "no such page");
|
||||
let page = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap();
|
||||
isolate_content(&mut doc, 0, 1).unwrap();
|
||||
let after = streams(&doc, 0).join("\n");
|
||||
assert!(after.contains("(Second) Tj") && !after.contains("(First) Tj"), "{after}");
|
||||
let page = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
assert!(page.dict.get(b"Annots").is_none(), "no annotations are drawn over it");
|
||||
assert_eq!(list_added(&doc).len(), 1);
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste (#2636): where a pasted item lands.
|
||||
#[test]
|
||||
fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() {
|
||||
@@ -4978,6 +5026,40 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Report generators draw each picture as `q re W n cm Do Q` inside a section's clip that the
|
||||
/// section's text shares. Resizing the picture scales its own clip with it (the enlarged picture
|
||||
/// used to show only through its old box) while the section's clip and text stay put.
|
||||
#[test]
|
||||
fn resizing_an_image_under_a_shared_clip_moves_its_own_clip() {
|
||||
// The picture's own clip as wide as the picture, and wider (both seen in one report).
|
||||
for clip_width in [61.2, 85.0] {
|
||||
let mut doc = image_page_with(&format!(
|
||||
"q 20 20 560 760 re W n q 162 79 {clip_width} 28.3 re W n 61.2 0 0 28.3 162 79 cm /Im0 Do Q BT /F1 12 Tf 30 50 Td (Section text) Tj ET Q"
|
||||
));
|
||||
let before = images::page_images(&doc, 0).unwrap()[0].clone();
|
||||
assert!(close(before.visible_rect.unwrap(), [162.0, 79.0, 223.2, 107.3]), "{:?}", before.visible_rect);
|
||||
let caption = text::text_lines(&doc, 0).unwrap();
|
||||
let target = [162.0, 79.0, 284.4, 135.6];
|
||||
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let after = images::page_images(&doc, 0).unwrap()[0].clone();
|
||||
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05);
|
||||
assert!(near(after.visible_rect.unwrap(), target), "clip {clip_width}: the whole enlarged picture shows, {:?}", after.visible_rect);
|
||||
assert_eq!(after.object, before.object, "pixels are never resampled or replaced");
|
||||
assert_eq!(
|
||||
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
|
||||
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
|
||||
"the section's text stays where it was"
|
||||
);
|
||||
// The section's clip is shared, so it still bounds the picture: past it, nothing shows.
|
||||
let past = images::rect_to_rect(after.visible_rect.unwrap(), [500.0, 79.0, 700.0, 135.6]);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(past)).unwrap();
|
||||
let clipped = images::page_images(&reopen(&doc), 0).unwrap()[0].visible_rect.unwrap();
|
||||
assert!(clipped[2] <= 580.05, "the section's clip stays in force: {clipped:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_image_crop_with_intervening_state_resizes_across_streams() {
|
||||
let variants: Vec<Vec<&[u8]>> = vec![
|
||||
|
||||
+34
-3420
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,424 @@
|
||||
use super::interp::{Carry, interpret};
|
||||
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
|
||||
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
|
||||
use super::*;
|
||||
|
||||
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
|
||||
/// A region may contain several source fonts, sizes, colours and weights.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextBlock {
|
||||
/// The lines' text joined with spaces (lines that ended in a hyphen are joined directly).
|
||||
pub text: String,
|
||||
pub rect: [f64; 4],
|
||||
pub base_font: String,
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// The text fill colour as RGB, used by the visual editor and preserved when no colour
|
||||
/// override is requested.
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in page user space, in degrees.
|
||||
pub rotation: f64,
|
||||
/// Indexes into [`text_lines`].
|
||||
pub lines: Vec<usize>,
|
||||
/// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs.
|
||||
pub rows: Vec<Vec<usize>>,
|
||||
/// Text for the inline editor, with a newline only between visual rows.
|
||||
pub editor_text: String,
|
||||
/// Byte ranges in `editor_text` and their source line indexes.
|
||||
pub spans: Vec<(std::ops::Range<usize>, usize)>,
|
||||
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
|
||||
/// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]).
|
||||
pub invisible: bool,
|
||||
}
|
||||
|
||||
impl TextBlock {
|
||||
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
|
||||
/// source run. The texts are aligned word by word, so words left untouched between two edits
|
||||
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
|
||||
/// Byte ranges are always UTF-8 boundaries. Linear time, including large edits.
|
||||
pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range<usize>, usize)> {
|
||||
if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 {
|
||||
return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))];
|
||||
}
|
||||
let old: Vec<_> = self.editor_text.chars().collect();
|
||||
let new: Vec<_> = draft.char_indices().collect();
|
||||
let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect();
|
||||
let mut original = Vec::with_capacity(old.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) {
|
||||
span += 1;
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
let mut sources = Vec::with_capacity(new.len());
|
||||
let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::<Vec<_>>();
|
||||
let (old_words, new_words) = (slices(&old), slices(&chars));
|
||||
match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) {
|
||||
Some(script) => {
|
||||
// `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends.
|
||||
let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0));
|
||||
let (mut a, mut b) = (old_words.iter(), new_words.iter());
|
||||
for step in script {
|
||||
match step {
|
||||
Step::Keep => {
|
||||
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
|
||||
let len = a.next().map_or(0, Vec::len);
|
||||
b.next();
|
||||
sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default());
|
||||
(x, y) = (x.saturating_add(len), y.saturating_add(len));
|
||||
(o, n) = (x, y);
|
||||
}
|
||||
Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)),
|
||||
Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)),
|
||||
}
|
||||
}
|
||||
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
|
||||
}
|
||||
None => replaced(&original, 0, &old, &chars, &mut sources),
|
||||
}
|
||||
let fallback = original.first().copied().unwrap_or(0);
|
||||
let mut result: Vec<(std::ops::Range<usize>, usize)> = Vec::new();
|
||||
for (i, (byte, ch)) in new.iter().enumerate() {
|
||||
let source = sources.get(i).copied().unwrap_or(fallback);
|
||||
let end = byte.saturating_add(ch.len_utf8());
|
||||
if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) {
|
||||
range.end = end;
|
||||
} else {
|
||||
result.push((*byte..end, source));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change.
|
||||
/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear.
|
||||
const MAX_CHANGED_WORDS: usize = 64;
|
||||
|
||||
/// `text` split into words, each with the whitespace after it, as char ranges.
|
||||
fn words(text: &[char]) -> Vec<std::ops::Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, pair) in text.windows(2).enumerate() {
|
||||
if let [a, b] = pair
|
||||
&& a.is_whitespace()
|
||||
&& !b.is_whitespace()
|
||||
{
|
||||
out.push(start..i + 1);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if start < text.len() {
|
||||
out.push(start..text.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Step {
|
||||
Keep,
|
||||
Delete,
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps.
|
||||
fn edit_script<T: PartialEq>(a: &[T], b: &[T], max: usize) -> Option<Vec<Step>> {
|
||||
let (n, m) = (a.len() as isize, b.len() as isize);
|
||||
let max = max.min(a.len().saturating_add(b.len())) as isize;
|
||||
// Furthest x reached on each diagonal k = x - y, stored at k + max + 1.
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
let mut v = vec![0isize; (2 * max + 3) as usize];
|
||||
let mut trace = Vec::new();
|
||||
for d in 0..=max {
|
||||
trace.push(v.clone());
|
||||
for k in (-d..=d).step_by(2) {
|
||||
let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1));
|
||||
let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 };
|
||||
let mut y = x - k;
|
||||
while x < n && y < m && a.get(x as usize) == b.get(y as usize) {
|
||||
x += 1;
|
||||
y += 1;
|
||||
}
|
||||
if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) {
|
||||
*slot = x;
|
||||
}
|
||||
if x >= n && y >= m {
|
||||
return Some(backtrack(&trace, n, m, max));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk Myers' saved frontiers back from the end to the edit script.
|
||||
fn backtrack(trace: &[Vec<isize>], n: isize, m: isize, max: isize) -> Vec<Step> {
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
let mut steps = Vec::new();
|
||||
let (mut x, mut y) = (n, m);
|
||||
for (d, v) in trace.iter().enumerate().rev() {
|
||||
let (d, k) = (d as isize, x - y);
|
||||
let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 };
|
||||
let prev_x = at(v, prev_k);
|
||||
let prev_y = prev_x - prev_k;
|
||||
while x > prev_x && y > prev_y {
|
||||
steps.push(Step::Keep);
|
||||
x -= 1;
|
||||
y -= 1;
|
||||
}
|
||||
if d > 0 {
|
||||
steps.push(if x == prev_x { Step::Insert } else { Step::Delete });
|
||||
}
|
||||
(x, y) = (prev_x, prev_y);
|
||||
}
|
||||
steps.reverse();
|
||||
steps
|
||||
}
|
||||
|
||||
/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their
|
||||
/// runs, and the rest inherit the run just before the change (or the first one it replaces).
|
||||
fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec<usize>) {
|
||||
let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
|
||||
let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count();
|
||||
let start = at.saturating_add(prefix);
|
||||
let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0);
|
||||
for i in 0..new.len() {
|
||||
let source = if i < prefix {
|
||||
original.get(at.saturating_add(i)).copied()
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied()
|
||||
} else {
|
||||
Some(inherited)
|
||||
};
|
||||
out.push(source.unwrap_or(inherited));
|
||||
}
|
||||
}
|
||||
|
||||
/// How a listing of [`text_blocks`] records a paragraph so that it can be found again.
|
||||
///
|
||||
/// The index of a paragraph is its position in a list regrouped from geometry on every read, so
|
||||
/// an edit that merges or splits paragraphs makes the same index name another one (#2516). A key
|
||||
/// names the paragraph by what it says and where it is; a paragraph that no longer exists as
|
||||
/// such has no match, instead of a neighbour taking its place.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BlockKey {
|
||||
text: String,
|
||||
rect: [f64; 4],
|
||||
}
|
||||
|
||||
impl BlockKey {
|
||||
/// Two boxes are the same box within this many points.
|
||||
const TOLERANCE: f64 = 0.01;
|
||||
|
||||
pub fn of(block: &TextBlock) -> Self {
|
||||
Self { text: block.text.clone(), rect: block.rect }
|
||||
}
|
||||
|
||||
/// The paragraph's box when it was recorded (user space).
|
||||
pub fn rect(&self) -> [f64; 4] {
|
||||
self.rect
|
||||
}
|
||||
|
||||
/// The paragraph in `blocks` with this text and box. `hint` is tried first, so two
|
||||
/// paragraphs drawn on top of one another are told apart by their order.
|
||||
pub fn find(&self, blocks: &[TextBlock], hint: usize) -> Option<usize> {
|
||||
let matches = |b: &TextBlock| b.text == self.text && b.rect.iter().zip(&self.rect).all(|(a, b)| (a - b).abs() <= Self::TOLERANCE);
|
||||
match blocks.get(hint) {
|
||||
Some(b) if matches(b) => Some(hint),
|
||||
_ => blocks.iter().position(matches),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph in `blocks` whose box overlaps `rect` the most, if any overlaps it at all. An
|
||||
/// edit rewrites a paragraph in place, so this is the paragraph it became.
|
||||
pub fn block_overlapping(blocks: &[TextBlock], rect: [f64; 4]) -> Option<usize> {
|
||||
// `f64::min` and `max` skip a NaN, which would make a box that isn't a number overlap anything.
|
||||
let area = |b: &TextBlock| {
|
||||
if !rect.iter().chain(&b.rect).all(|v| v.is_finite()) {
|
||||
return 0.0;
|
||||
}
|
||||
let w = b.rect[2].min(rect[2]) - b.rect[0].max(rect[0]);
|
||||
let h = b.rect[3].min(rect[3]) - b.rect[1].max(rect[1]);
|
||||
if w > 0.0 && h > 0.0 { w * h } else { 0.0 }
|
||||
};
|
||||
blocks.iter().enumerate().map(|(i, b)| (i, area(b))).filter(|(_, a)| *a > 0.0).max_by(|(_, a), (_, b)| a.total_cmp(b)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The paragraphs on a page (0-based).
|
||||
pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
|
||||
let lines = text_lines(doc, page)?;
|
||||
Ok(group_blocks(&lines))
|
||||
}
|
||||
|
||||
/// Remove a paragraph's glyphs for a temporary editor background. Numeric TJ adjustments
|
||||
/// preserve the text matrix, so unselected text positioned relative to it stays in place.
|
||||
/// Call this on a clone: it is a preview operation, not a document edit.
|
||||
pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), EditError> {
|
||||
let lines = text_lines(doc, page)?;
|
||||
let blocks = group_blocks(&lines);
|
||||
let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?;
|
||||
let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect();
|
||||
targets.iter().try_for_each(|line| line.ensure_editable())?;
|
||||
let mut drops = coincident_ops(&lines, &targets);
|
||||
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
|
||||
let p = page_dict(doc, page)?;
|
||||
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
|
||||
let mut patches = HashMap::new();
|
||||
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
|
||||
if s.state.render_mode >= 4 {
|
||||
return Err(EditError::Invalid("text used as a clipping path cannot be previewed for editing".into()));
|
||||
}
|
||||
let denominator = s.state.size * s.state.scale;
|
||||
let adjustment = if denominator != 0.0 { -1000.0 * s.advance / denominator } else { 0.0 };
|
||||
if !adjustment.is_finite() || (denominator == 0.0 && s.advance != 0.0) {
|
||||
return Err(EditError::Invalid("the paragraph's text advance cannot be previewed".into()));
|
||||
}
|
||||
let mut replacement = Vec::new();
|
||||
if let Some(op) = ops.get(s.op) {
|
||||
if op.is("\"") {
|
||||
replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))]));
|
||||
replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))]));
|
||||
}
|
||||
if op.is("'") || op.is("\"") {
|
||||
replacement.push(Op::new("T*", vec![]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
|
||||
patches.insert(s.op, replacement);
|
||||
}
|
||||
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
|
||||
let contents = rewritten_contents(doc, &streams, data);
|
||||
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects
|
||||
/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not
|
||||
/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`].
|
||||
/// [`reading_blocks_checked`] also says whether text was left out.
|
||||
pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
|
||||
reading_blocks_checked(doc, page).map(|r| r.blocks)
|
||||
}
|
||||
|
||||
/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ReadingBlocks {
|
||||
/// The paragraphs, as [`reading_blocks`] returns them.
|
||||
pub blocks: Vec<TextBlock>,
|
||||
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than
|
||||
/// [`MAX_FORM_VISITS`], or forms that run past what the page's content left of its decode
|
||||
/// budget. Reading leaves those forms out to stay bounded on hostile files, so their text
|
||||
/// is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
|
||||
/// their images too.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// [`reading_blocks`], saying whether the limits that bound reading left text out. A page
|
||||
/// whose `/Contents` alone runs past its decode budget is an [`EditError::Invalid`], not a
|
||||
/// truncated result.
|
||||
pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result<ReadingBlocks, EditError> {
|
||||
let (lines, truncated) = reading_lines(doc, page)?;
|
||||
Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated })
|
||||
}
|
||||
|
||||
// An ideographic space carries intentional Japanese spacing, including paragraph indentation.
|
||||
// Keep other whitespace normalization unchanged.
|
||||
pub(crate) fn paragraph_separator(c: char) -> bool {
|
||||
c.is_whitespace() && c != '\u{3000}'
|
||||
}
|
||||
|
||||
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
|
||||
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
|
||||
/// those pieces join without a space. Every white-space character is written as one space, so
|
||||
/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a
|
||||
/// row wraps are dropped.
|
||||
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for row in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let start = out.len();
|
||||
// The separators read since the last word: each one boundary between `split` pieces.
|
||||
let mut gap = 0usize;
|
||||
for (k, word) in row.split(paragraph_separator).enumerate() {
|
||||
if k > 0 {
|
||||
gap = gap.saturating_add(1);
|
||||
}
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let spaces = " ".repeat(std::mem::take(&mut gap));
|
||||
for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() {
|
||||
let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") };
|
||||
// A row holding only the spaces that indent it doesn't wrap before its first word.
|
||||
if line.chars().any(|c| c != ' ') && advance(&candidate) > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = piece.to_string();
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
line.push_str(&" ".repeat(gap));
|
||||
if !line.is_empty() || out.len() == start {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace paragraph `block` (an index into [`text_blocks`]) with `text`, rewrapped to the
|
||||
/// paragraph's width with its line spacing. The paragraph keeps its first line's position, font
|
||||
/// (or Helvetica when the font can't show the text), size and colour.
|
||||
pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) -> Result<LineEdit, EditError> {
|
||||
rewrite_block(doc, page, block, Some(text), &BlockStyle::default())
|
||||
}
|
||||
|
||||
/// Formatting for a rewritten paragraph (`None` keeps the paragraph's own).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BlockStyle {
|
||||
/// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style.
|
||||
pub ranges: Vec<RangeStyle>,
|
||||
/// Explicit page font resource (from register_text_font or the page font inventory).
|
||||
pub resource: Option<String>,
|
||||
/// A standard font family, bold, italic.
|
||||
pub family: Option<(crate::added::Family, bool, bool)>,
|
||||
/// Override weight without choosing a different family (`None` keeps the source weight).
|
||||
pub bold: Option<bool>,
|
||||
/// Font size in points (user space).
|
||||
pub size: Option<f64>,
|
||||
/// Fill colour (RGB 0–1).
|
||||
pub color: Option<[f64; 3]>,
|
||||
pub align: Option<crate::added::Align>,
|
||||
/// Draw a line under each line of text.
|
||||
pub underline: Option<bool>,
|
||||
/// Line spacing as a multiple of the font size (1.2 is ordinary).
|
||||
pub line_spacing: Option<f64>,
|
||||
/// Character spacing (points) and horizontal scaling (percent).
|
||||
pub char_spacing: Option<f64>,
|
||||
pub scale: Option<f64>,
|
||||
/// Move the paragraph by `[dx, dy]` in user space (dragging its box).
|
||||
pub offset: Option<[f64; 2]>,
|
||||
/// Rewrap to this width in user space (dragging the box's edge).
|
||||
pub width: Option<f64>,
|
||||
/// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour
|
||||
/// (RGB 0–1) before the new text, hiding the scanned word beneath it.
|
||||
pub cover: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RangeStyle {
|
||||
pub range: std::ops::Range<usize>,
|
||||
pub family: Option<(crate::added::Family, bool, bool)>,
|
||||
pub resource: Option<String>,
|
||||
pub size: Option<f64>,
|
||||
pub color: Option<[f64; 3]>,
|
||||
pub underline: Option<bool>,
|
||||
pub char_spacing: Option<f64>,
|
||||
pub scale: Option<f64>,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use super::blocks::{BlockStyle, TextBlock};
|
||||
use super::fallback::MAX_TYPE3_GLYPHS;
|
||||
use super::lines::{page_dict, text_lines};
|
||||
use super::rewrite::rewrite_block_from;
|
||||
use super::*;
|
||||
|
||||
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
|
||||
/// are never installed, copied into assets, or used as a font for another document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextEditorFont {
|
||||
pub resource: String,
|
||||
pub face: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFont>, EditError> {
|
||||
let p = page_dict(doc, page)?;
|
||||
let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let mut budget = 32_usize << 20;
|
||||
// Every program decoded counts, usable or not, so aliases of one program can't repeat the work.
|
||||
let mut decode = DecodeBudget::new(2 * budget, 0);
|
||||
let mut result = Vec::new();
|
||||
for (name, font) in fonts.iter().take(128) {
|
||||
let Some(font) = doc.dict(font) else { continue };
|
||||
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt_within(doc, &font, &mut decode) else { continue };
|
||||
if bytes.len() > budget {
|
||||
continue;
|
||||
}
|
||||
budget -= bytes.len();
|
||||
result.push(TextEditorFont {
|
||||
resource: String::from_utf8_lossy(name).into_owned(),
|
||||
face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(),
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
// The approved full face follows all source subsets in the editor's font stack. It can
|
||||
// display newly inserted glyphs before Apply without changing the source text's face.
|
||||
let source_faces: Vec<_> = result.iter().map(|p| p.face.clone()).collect();
|
||||
for face in source_faces {
|
||||
for full in pdfcraft_fonts::embedding::matching_faces(&face) {
|
||||
if full.bytes.len() > budget || result.iter().any(|p| p.resource == format!("PCApproved{}", full.name())) {
|
||||
continue;
|
||||
}
|
||||
budget -= full.bytes.len();
|
||||
result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() });
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Load an explicit full face into this page's resources. Nothing on the page is replaced;
|
||||
/// the new resource can complete matching source subsets or be chosen in a text style.
|
||||
pub fn register_text_font(doc: &mut Document, page: usize, bytes: &[u8]) -> Result<String, EditError> {
|
||||
pdfcraft_fonts::embedding::validate_document_input(bytes).map_err(EditError::Invalid)?;
|
||||
let (_, postscript) = pdfcraft_fonts::embedding::names(bytes).map_err(EditError::Invalid)?;
|
||||
let p = page_dict(doc, page)?;
|
||||
let mut resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let mut fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, bytes, &postscript).map_err(EditError::Invalid)?;
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources)))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Read-only draft bounds, measured with the same PDF rewrite used by Apply/Save.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextEditLayout {
|
||||
pub source_rect: [f64; 4],
|
||||
pub draft_rect: Option<[f64; 4]>,
|
||||
pub overflow: bool,
|
||||
}
|
||||
|
||||
pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result<TextEditLayout, EditError> {
|
||||
TextSession::open(doc, page, block)?.layout(text, style)
|
||||
}
|
||||
|
||||
/// A draft of paragraph `source` (number `block` among the page's `lines`) laid out by the rewrite
|
||||
/// Apply runs, on a copy of `doc`.
|
||||
pub(crate) fn layout_draft(
|
||||
doc: &Document,
|
||||
page: usize,
|
||||
lines: &[TextLine],
|
||||
source: &TextBlock,
|
||||
block: usize,
|
||||
text: &str,
|
||||
style: &BlockStyle,
|
||||
) -> Result<TextEditLayout, EditError> {
|
||||
if text == source.editor_text && *style == BlockStyle::default() {
|
||||
return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false });
|
||||
}
|
||||
// Subtract the untouched runs by value, rather than by operator offsets: a rewrite may
|
||||
// split BT/ET or add font resources. A multiset also handles coincident repeated text.
|
||||
let key =
|
||||
|line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits));
|
||||
let mut untouched = std::collections::BTreeMap::new();
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
if !source.lines.contains(&index) {
|
||||
*untouched.entry(key(line)).or_insert(0_usize) += 1;
|
||||
}
|
||||
}
|
||||
let mut draft = doc.clone();
|
||||
rewrite_block_from(&mut draft, page, lines.to_vec(), block, Some(text), style, MAX_TYPE3_GLYPHS)?;
|
||||
let mut rect: Option<[f64; 4]> = None;
|
||||
for line in text_lines(&draft, page)? {
|
||||
if let Some(count) = untouched.get_mut(&key(&line))
|
||||
&& *count > 0
|
||||
{
|
||||
*count -= 1;
|
||||
continue;
|
||||
}
|
||||
// Spaces typed after a row's last word are drawn but show nothing, so an upright row is
|
||||
// measured to the end of its last word: they don't make a draft overflow (#4199).
|
||||
let mut r = line.rect;
|
||||
if line.upright() && !line.ink.is_empty() {
|
||||
if crate::bidi::rtl_base(&line.text) {
|
||||
r[0] = r[0].max(line.ink.iter().map(|i| i[0]).fold(f64::INFINITY, f64::min));
|
||||
} else {
|
||||
r[2] = r[2].min(line.ink.iter().map(|i| i[2]).fold(f64::NEG_INFINITY, f64::max));
|
||||
}
|
||||
}
|
||||
rect = Some(rect.map_or(r, |a| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]));
|
||||
}
|
||||
let overflow = rect
|
||||
.is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5);
|
||||
Ok(TextEditLayout { source_rect: source.rect, draft_rect: rect, overflow })
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
use super::interp::Ts;
|
||||
use super::lines::page_dict;
|
||||
use super::*;
|
||||
|
||||
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
|
||||
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
fn is_win_ansi_char(c: char) -> bool {
|
||||
c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn needs_type3(text: &str) -> bool {
|
||||
text.chars().any(|c| !is_win_ansi_char(c))
|
||||
}
|
||||
|
||||
/// The `/Name` of the Type 3 fonts generated for Japanese text.
|
||||
const JAPANESE_FONT_NAME: &str = "PCJapanese";
|
||||
|
||||
/// The `/Name` of the Type 3 fonts generated for Hebrew text.
|
||||
const HEBREW_FONT_NAME: &str = "PCHebrew";
|
||||
|
||||
/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face.
|
||||
const LATIN_FONT_NAME: &str = "PCLatin";
|
||||
|
||||
/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer
|
||||
/// than one of them is split across several, which read back as one line (see [`same_font`]).
|
||||
pub(super) fn is_type3_fallback(font: &Dict) -> bool {
|
||||
font.name(b"Subtype") == Some(b"Type3")
|
||||
&& font
|
||||
.name(b"Name")
|
||||
.is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes())
|
||||
}
|
||||
|
||||
/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the
|
||||
/// Japanese ones.
|
||||
fn fallback_names(face: &CraftFont, hebrew: bool) -> (&'static str, &'static str) {
|
||||
if hebrew {
|
||||
(HEBREW_FONT_NAME, "PCHe")
|
||||
} else if face.family == "Inter" {
|
||||
(LATIN_FONT_NAME, "PCLat")
|
||||
} else {
|
||||
(JAPANESE_FONT_NAME, "PCJp")
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the generated fallback fonts ([`is_type3_fallback`]) that nothing on `page` shows any more.
|
||||
/// Each rewrite generates its own, with the outlines of every character it draws, so without this
|
||||
/// an earlier rewrite's fonts stay in the page's resources and the file. Only a font dictionary the
|
||||
/// page owns is changed (a rewrite gives the page its own), and nothing is dropped unless every
|
||||
/// content stream that may name the page's fonts can be read: the page's own, and those of form
|
||||
/// XObjects and Type 3 glyphs that have no resources of their own and so use the page's.
|
||||
pub(super) fn drop_unused_fallback_fonts(doc: &mut Document, page: usize) {
|
||||
let Ok(p) = page_dict(doc, page) else { return };
|
||||
let Some(Object::Dict(mut resources)) = p.dict.get(b"Resources").cloned() else { return };
|
||||
let Some(mut fonts) = resources.get(b"Font").and_then(|f| doc.dict(f)) else { return };
|
||||
let generated: Vec<Vec<u8>> =
|
||||
fonts.iter().filter(|(_, f)| doc.dict(f).is_some_and(|d| is_type3_fallback(&d))).map(|(name, _)| name.clone()).collect();
|
||||
if generated.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(shown) = fonts_shown(doc, &p.dict, &resources) else { return };
|
||||
let unused: Vec<&Vec<u8>> = generated.iter().filter(|name| !shown.contains(*name)).collect();
|
||||
if unused.is_empty() {
|
||||
return;
|
||||
}
|
||||
for name in unused {
|
||||
fonts.remove(name);
|
||||
}
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
// Nothing is lost if this fails: the fonts just stay.
|
||||
let _ = doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources)));
|
||||
}
|
||||
|
||||
/// The font resource names `Tf` selects in the page's content and in the content that uses the
|
||||
/// page's resources (see [`drop_unused_fallback_fonts`]), or `None` if any of it can't be read.
|
||||
fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option<std::collections::HashSet<Vec<u8>>> {
|
||||
let mut streams: Vec<Object> = match page.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
// Form XObjects without their own resources use the page's, however deeply they are nested
|
||||
// (ISO 32000-1 §7.8.3), so every form the page can reach is visited.
|
||||
let mut pending: Vec<Dict> = vec![resources.clone()];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(res) = pending.pop() {
|
||||
for (_, x) in res.get(b"XObject").and_then(|x| doc.dict(x)).unwrap_or_default().iter() {
|
||||
let Some(r) = x.as_ref() else { continue };
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
let Object::Stream(s) = &*doc.get(r) else { continue };
|
||||
if s.dict.name(b"Subtype") != Some(b"Form") {
|
||||
continue;
|
||||
}
|
||||
match s.dict.get(b"Resources").and_then(|r| doc.dict(r)) {
|
||||
Some(own) => pending.push(own),
|
||||
None => streams.push(x.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// And so do Type 3 glyph procedures without their own (the generated fonts' are outlines only).
|
||||
for (_, f) in resources.get(b"Font").and_then(|f| doc.dict(f)).unwrap_or_default().iter() {
|
||||
if let Some(font) = doc.dict(f).filter(|d| d.name(b"Subtype") == Some(b"Type3") && !d.contains(b"Resources") && !is_type3_fallback(d)) {
|
||||
streams.extend(font.get(b"CharProcs").and_then(|c| doc.dict(c)).unwrap_or_default().iter().map(|(_, proc)| proc.clone()));
|
||||
}
|
||||
}
|
||||
let mut shown = std::collections::HashSet::new();
|
||||
for stream in streams {
|
||||
let Object::Stream(s) = &*doc.resolve(&stream) else { return None };
|
||||
// Strict: a stream only partly read might name a font in the part that wasn't.
|
||||
let data = s.decoded_strict().ok()?;
|
||||
for op in parse(&data).ops {
|
||||
if op.is("Tf")
|
||||
&& let Some(name) = op.operands.first().and_then(|n| n.as_name())
|
||||
{
|
||||
shown.insert(name.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese
|
||||
/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another
|
||||
/// resource for the same font (a writer may split one font into several subsets, #3661), or
|
||||
/// another subset of the same face.
|
||||
pub(crate) fn same_font(line: &TextLine, font: &[u8], base_font: &str, face: Option<&str>) -> bool {
|
||||
line.font.as_bytes() == font || same_base_font(&line.base_font, base_font) || (line.face.is_some() && line.face.as_deref() == face)
|
||||
}
|
||||
|
||||
/// Whether a run painted with `ts` continues `line` as it is painted: a line is rewritten in the
|
||||
/// state of its first run, so a `Tr`, stroke colour or line width that changes partway starts
|
||||
/// another run (see [`TextState::paint_ops`]; the `gs` parameters are compared as [`Paint`]).
|
||||
/// The stroke colour, width, dash and cap only count for text that is stroked, so a rule drawn
|
||||
/// between two words doesn't split their line. They are compared as values, wherever they were
|
||||
/// set: two ExtGStates with the same settings under other names still continue a line.
|
||||
///
|
||||
/// Only the visible modes (`Tr` 0–2) that `paint_ops` restates are compared: a line that goes on
|
||||
/// as invisible or clipping text stays one line, which the clipping refusal reads whole (#3447).
|
||||
pub(super) fn same_paint(line: &TextLine, ts: &Ts) -> bool {
|
||||
let a = &line.origin.state;
|
||||
let visible = |m: i64| (0..=2).contains(&m);
|
||||
let same_mode = a.render_mode == ts.render_mode || !visible(a.render_mode) || !visible(ts.render_mode);
|
||||
let stroked = matches!((a.render_mode, ts.render_mode), (1 | 2, 1 | 2));
|
||||
let outline = a.stroke == ts.stroke && a.line_width == ts.line_width && a.dash == ts.dash && a.line_cap == ts.line_cap;
|
||||
same_mode && (!stroked || outline)
|
||||
}
|
||||
|
||||
/// Whether two `/BaseFont` names name the same font: a subset's six-letter tag and `+` prefix do
|
||||
/// not change the font (ISO 32000-2 §9.6.4), so `ABCDEF+Arial` and `GHIJKL+Arial` are one font.
|
||||
/// A name that is missing, or only a tag (`ABCDEF+`), names no font and matches nothing.
|
||||
pub(crate) fn same_base_font(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (strip_subset_tag(a), strip_subset_tag(b));
|
||||
!a.is_empty() && a == b
|
||||
}
|
||||
|
||||
/// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged.
|
||||
pub(crate) fn strip_subset_tag(name: &str) -> &str {
|
||||
let bytes = name.as_bytes();
|
||||
let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase));
|
||||
if is_tag { name.get(7..).unwrap_or(name) } else { name }
|
||||
}
|
||||
|
||||
pub(crate) fn source_family(base_font: &str) -> crate::added::Family {
|
||||
let name = base_font.to_ascii_lowercase();
|
||||
if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
|
||||
crate::added::Family::Courier
|
||||
} else if !name.contains("sans")
|
||||
&& ["times", "serif", "roman", "mincho", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s))
|
||||
{
|
||||
crate::added::Family::Times
|
||||
} else {
|
||||
crate::added::Family::Helvetica
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const MAX_TYPE3_GLYPHS: usize = 240;
|
||||
|
||||
/// The generated fonts for one replacement: one per run of at most `max_glyphs` unique characters,
|
||||
/// in order of first appearance.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Type3Fallback {
|
||||
/// The family its glyphs come from ("Shippori Mincho").
|
||||
pub(crate) family: &'static str,
|
||||
/// The fonts' resource names in order; the first is `PCJp` (`PCLat` for the bundled Latin
|
||||
/// face, `PCHe` for Hebrew text).
|
||||
pub(crate) names: Vec<String>,
|
||||
/// Every unique character with its advance (em), in order of first appearance.
|
||||
pub(super) widths: Vec<(char, f64)>,
|
||||
/// Where each character is shown: the index of its font in `names`, and its code there.
|
||||
pub(crate) codes: HashMap<char, (usize, u8)>,
|
||||
}
|
||||
|
||||
/// One glyph of a generated font: its code there, its character, its glyph procedure and advance.
|
||||
pub(crate) struct Type3Glyph {
|
||||
pub(crate) code: u8,
|
||||
pub(crate) ch: char,
|
||||
pub(crate) path: Vec<u8>,
|
||||
pub(crate) width: f64,
|
||||
}
|
||||
|
||||
fn pdf_num(v: f64) -> String {
|
||||
if v.fract() == 0.0 { format!("{v:.0}") } else { format!("{v:.4}").trim_end_matches('0').trim_end_matches('.').to_string() }
|
||||
}
|
||||
|
||||
fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec<u8>, f64), EditError> {
|
||||
let glyph = japanese_glyph_from(face, ch).map_err(|e| match e {
|
||||
GlyphError::NoFont => no_japanese_font(),
|
||||
GlyphError::Missing => EditError::Invalid(format!("the {} fallback font has no glyph for U+{:04X}", face.family, ch as u32)),
|
||||
GlyphError::TooComplex => EditError::Invalid(format!("the {} fallback glyph U+{:04X} is too complex", face.family, ch as u32)),
|
||||
})?;
|
||||
let (dx, width) = proportional(ch, &glyph);
|
||||
let scale = 1000.0;
|
||||
// d1 is `wx wy llx lly urx ury` (ISO 32000-2 §9.6.4) with a box enclosing the glyph;
|
||||
// Acrobat draws a bullet in place of a glyph whose d1 is malformed.
|
||||
let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox };
|
||||
let mut out = format!(
|
||||
"{} 0 {} {} {} {} d1\n",
|
||||
pdf_num(width * scale),
|
||||
pdf_num(((b[0] + dx) * scale).floor()),
|
||||
pdf_num((b[1] * scale).floor()),
|
||||
pdf_num(((b[2] + dx) * scale).ceil()),
|
||||
pdf_num((b[3] * scale).ceil())
|
||||
)
|
||||
.into_bytes();
|
||||
for contour in glyph.contours {
|
||||
let Some(first) = contour.first() else { continue };
|
||||
out.extend_from_slice(format!("{} {} m\n", pdf_num((first[0] + dx) * scale), pdf_num(first[1] * scale)).as_bytes());
|
||||
for p in contour.iter().skip(1) {
|
||||
out.extend_from_slice(format!("{} {} l\n", pdf_num((p[0] + dx) * scale), pdf_num(p[1] * scale)).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"h\n");
|
||||
}
|
||||
out.extend_from_slice(b"f\n");
|
||||
Ok((out, width))
|
||||
}
|
||||
|
||||
/// Side bearing (em) given to a respaced glyph on each side.
|
||||
const SIDE_BEARING: f64 = 0.05;
|
||||
|
||||
/// Shift and advance for a fallback glyph. Japanese faces draw Cyrillic and Greek full-width
|
||||
/// (one em each, as in JIS X 0208) and have no proportional (`palt`) metrics for them, so a
|
||||
/// word set that way reads as letter-spaced. Those letters get their ink width plus a side
|
||||
/// bearing instead; everything else keeps the face's own advance.
|
||||
fn proportional(ch: char, glyph: &pdfcraft_fonts::GlyphOutline) -> (f64, f64) {
|
||||
let respace = matches!(ch, '\u{0370}'..='\u{03FF}' | '\u{0400}'..='\u{052F}' | '\u{1F00}'..='\u{1FFF}');
|
||||
let ink = glyph.bbox[2] - glyph.bbox[0];
|
||||
if !respace || glyph.contours.is_empty() || !(ink > 0.0 && ink < glyph.width) {
|
||||
return (0.0, glyph.width);
|
||||
}
|
||||
(SIDE_BEARING - glyph.bbox[0], ink + 2.0 * SIDE_BEARING)
|
||||
}
|
||||
|
||||
fn unicode_hex(ch: char) -> String {
|
||||
let mut units = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut units);
|
||||
encoded.iter().map(|u| format!("{u:04X}")).collect()
|
||||
}
|
||||
|
||||
/// The same outline for a face that may simply lack the glyph: `None` then, so the caller can try
|
||||
/// the next face (and report the character once none of them has it).
|
||||
pub(crate) fn type3_glyph(face: &CraftFont, ch: char) -> Result<Option<(Vec<u8>, f64)>, EditError> {
|
||||
match japanese_glyph_from(face, ch) {
|
||||
Err(GlyphError::Missing) => Ok(None),
|
||||
_ => type3_path(face, ch).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// No face this build has can draw `text`. Only Japanese text is about the Japanese face: other
|
||||
/// characters are missing from the paragraph's font, and choosing a font that has them works too.
|
||||
pub(crate) fn no_fallback_font(text: &str) -> EditError {
|
||||
if text.chars().any(needs_japanese) {
|
||||
return no_japanese_font();
|
||||
}
|
||||
let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() };
|
||||
EditError::Invalid(format!(
|
||||
"the paragraph's font has no glyph for {ch} (U+{:04X}) and this build has no fallback font for it; \
|
||||
choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \
|
||||
PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)",
|
||||
u32::from(ch)
|
||||
))
|
||||
}
|
||||
|
||||
/// The character the craft-fonts Japanese faces are for: kana, CJK ideographs and their compatibility
|
||||
/// and full-width forms.
|
||||
fn needs_japanese(ch: char) -> bool {
|
||||
matches!(u32::from(ch), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
|
||||
}
|
||||
|
||||
/// The chosen face has no outline for `ch`, and neither has any other: say which character it is and
|
||||
/// what the writer can do about it. A CJK character without a Japanese face in the build is about
|
||||
/// that build input, which is the one answer that changes it.
|
||||
pub(crate) fn no_glyph_for(face: &CraftFont, ch: char) -> EditError {
|
||||
if needs_japanese(ch) && pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
return no_japanese_font();
|
||||
}
|
||||
EditError::Invalid(format!(
|
||||
"the {} fallback font has no glyph for {ch} (U+{:04X}), and no fallback face covers the text; \
|
||||
choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \
|
||||
PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)",
|
||||
face.family,
|
||||
u32::from(ch)
|
||||
))
|
||||
}
|
||||
|
||||
/// This build has no Japanese face to draw replacement text with.
|
||||
pub(super) fn no_japanese_font() -> EditError {
|
||||
EditError::Invalid(
|
||||
"this text needs PdfCraft's Japanese fallback font, which this build doesn't include \
|
||||
(official releases do; to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout)"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first.
|
||||
pub(crate) fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> {
|
||||
faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied())
|
||||
}
|
||||
|
||||
/// This build has no Hebrew face to draw replacement text with.
|
||||
fn no_hebrew_font() -> EditError {
|
||||
EditError::Invalid(
|
||||
"this text needs PdfCraft's Hebrew fallback font (Noto Sans Hebrew), which this build doesn't include \
|
||||
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has it)"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the
|
||||
/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese faces
|
||||
/// and the bundled Latin one for anything it lacks; other text takes the Japanese faces, and the
|
||||
/// bundled Latin face behind them, which covers the Latin, Greek and Cyrillic letters they lack
|
||||
/// (#2593: the Japanese faces have no қ, ғ, ҳ, ә, ө, ү, ұ or һ).
|
||||
pub(crate) fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> {
|
||||
let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
|
||||
let latin = pdfcraft_fonts::document_latin_fonts(bold);
|
||||
if !text.chars().any(crate::bidi::is_hebrew) {
|
||||
return Ok((japanese.into_iter().chain(latin).collect(), false));
|
||||
}
|
||||
// Points are drawn on their letter with the font's positioning, which a one-glyph-per-
|
||||
// character Type 3 font can't do; refuse rather than print them beside the letter.
|
||||
if text.chars().any(crate::bidi::is_hebrew_mark) {
|
||||
return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into()));
|
||||
}
|
||||
let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?;
|
||||
Ok((std::iter::once(hebrew).chain(japanese).chain(latin).collect(), true))
|
||||
}
|
||||
|
||||
pub(super) fn type3_font(
|
||||
doc: &mut Document,
|
||||
fonts_res: &mut Dict,
|
||||
text: &str,
|
||||
family: crate::added::Family,
|
||||
bold: bool,
|
||||
max_glyphs: usize,
|
||||
) -> Result<Type3Fallback, EditError> {
|
||||
let (faces, hebrew) = type3_faces(text, family, bold)?;
|
||||
// Each unique character once, in order of first appearance.
|
||||
let mut chars = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for ch in text.chars() {
|
||||
if seen.insert(ch) {
|
||||
chars.push(ch);
|
||||
}
|
||||
}
|
||||
if chars.is_empty() {
|
||||
return Err(EditError::Invalid("replacement text is empty".into()));
|
||||
}
|
||||
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
|
||||
// character. When none has them all, the best face reports the character it lacks. Hebrew
|
||||
// text keeps the Hebrew face and takes each character it lacks from the next face that has it.
|
||||
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
|
||||
.ok_or_else(|| no_fallback_font(text))?;
|
||||
let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] };
|
||||
// Every outline is read before the document is touched, so a character that fails leaves it
|
||||
// as it was. Each font takes the next `per_font` characters; codes restart at 1 in each.
|
||||
let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let mut glyphs = Vec::with_capacity(chars.len());
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
// The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew
|
||||
// face lacks from the next face that has it); a character no face draws is reported once.
|
||||
let mut drawn = type3_glyph(face, ch)?;
|
||||
for f in others.iter().copied() {
|
||||
if drawn.is_some() {
|
||||
break;
|
||||
}
|
||||
drawn = type3_glyph(f, ch)?;
|
||||
}
|
||||
let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) };
|
||||
let code = u8::try_from((i % per_font).saturating_add(1))
|
||||
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
|
||||
glyphs.push(Type3Glyph { code, ch, path, width });
|
||||
}
|
||||
Ok(write_type3_glyphs(doc, fonts_res, face, hebrew, glyphs, per_font))
|
||||
}
|
||||
|
||||
/// Write already validated outlines; callers can prepare every face before adding any objects.
|
||||
pub(crate) fn write_type3_glyphs(
|
||||
doc: &mut Document,
|
||||
fonts_res: &mut Dict,
|
||||
face: &CraftFont,
|
||||
hebrew: bool,
|
||||
glyphs: Vec<Type3Glyph>,
|
||||
per_font: usize,
|
||||
) -> Type3Fallback {
|
||||
let per_font = per_font.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let mut codes = HashMap::with_capacity(glyphs.len());
|
||||
let mut widths = Vec::with_capacity(glyphs.len());
|
||||
let mut names = Vec::new();
|
||||
let mut glyphs = glyphs.into_iter();
|
||||
loop {
|
||||
let group: Vec<Type3Glyph> = glyphs.by_ref().take(per_font).collect();
|
||||
if group.is_empty() {
|
||||
break;
|
||||
}
|
||||
for glyph in &group {
|
||||
codes.insert(glyph.ch, (names.len(), glyph.code));
|
||||
widths.push((glyph.ch, glyph.width));
|
||||
}
|
||||
names.push(add_type3_font(doc, fonts_res, face, hebrew, group));
|
||||
}
|
||||
Type3Fallback { family: face.family, names, widths, codes }
|
||||
}
|
||||
|
||||
/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource
|
||||
/// name (`PCLat`, `PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the
|
||||
/// outlines were read already.
|
||||
pub(super) fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec<Type3Glyph>) -> String {
|
||||
let last_char = glyphs.len();
|
||||
let mut charprocs = Dict::new();
|
||||
let mut widths = Vec::with_capacity(last_char);
|
||||
let mut differences = vec![Object::Int(1)];
|
||||
let mut cmap = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
|
||||
);
|
||||
cmap.push_str(&format!("{last_char} beginbfchar\n"));
|
||||
for glyph in glyphs {
|
||||
let glyph_name = format!("g{:02X}", glyph.code);
|
||||
let mut pd = Dict::new();
|
||||
pd.set(b"Length".to_vec(), glyph.path.len() as i64);
|
||||
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, glyph.path)));
|
||||
charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref));
|
||||
differences.push(Object::name(&glyph_name));
|
||||
widths.push(Object::Real((glyph.width * 1000.0).round()));
|
||||
cmap.push_str(&format!("<{:02X}> <{}>\n", glyph.code, unicode_hex(glyph.ch)));
|
||||
}
|
||||
cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
let mut cmap_dict = Dict::new();
|
||||
cmap_dict.set(b"Length".to_vec(), cmap.len() as i64);
|
||||
let cmap_ref = doc.add(Object::Stream(Stream::from_raw(cmap_dict, cmap.into_bytes())));
|
||||
let mut encoding = Dict::new();
|
||||
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
|
||||
encoding.set(b"Differences".to_vec(), Object::Array(differences));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type3"));
|
||||
let (font_name, prefix) = fallback_names(face, hebrew);
|
||||
font.set(b"Name".to_vec(), Object::name(font_name));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', "")));
|
||||
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(face.family.as_bytes().to_vec())));
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(if face.family.contains("Mincho") { 6 } else { 4 }));
|
||||
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
|
||||
// PDF 1.7 tables 5.9 and 5.19: a Type 3 descriptor is indirect; Ascent/Descent may be omitted.
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
|
||||
font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)]));
|
||||
font.set(
|
||||
b"FontMatrix".to_vec(),
|
||||
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
|
||||
);
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(1));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(last_char as i64));
|
||||
font.set(b"Widths".to_vec(), Object::Array(widths));
|
||||
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
|
||||
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref));
|
||||
let mut name = String::from(prefix);
|
||||
let mut suffix = 0usize;
|
||||
while fonts_res.contains(name.as_bytes()) {
|
||||
suffix = suffix.saturating_add(1);
|
||||
name = format!("{prefix}{suffix}");
|
||||
}
|
||||
fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font));
|
||||
name
|
||||
}
|
||||
|
||||
/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so
|
||||
/// a font is switched only where the text moves to another font. `None` if a character has no code.
|
||||
pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec<u8>)>> {
|
||||
let mut runs: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
for ch in text.chars() {
|
||||
let (font, code) = *fallback.codes.get(&ch)?;
|
||||
match runs.last_mut() {
|
||||
Some((last, bytes)) if *last == font => bytes.push(code),
|
||||
_ => runs.push((font, vec![code])),
|
||||
}
|
||||
}
|
||||
runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect()
|
||||
}
|
||||
|
||||
/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose
|
||||
/// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep
|
||||
/// its mapping there after the subsetter dropped its outline.
|
||||
pub(crate) fn source_program(doc: &Document, fonts: &Dict, name: &str) -> Option<Vec<u8>> {
|
||||
let font = fonts.get(name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())?;
|
||||
let base = Metrics::from_dict(doc, &font).base_font;
|
||||
if base.split_once('+').is_some_and(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())) {
|
||||
return None;
|
||||
}
|
||||
let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)?;
|
||||
pdfcraft_fonts::embedding::validate(&bytes).is_ok().then_some(bytes)
|
||||
}
|
||||
|
||||
/// Whether `text` has characters `line`'s font can't encode that its own program can draw.
|
||||
pub(super) fn source_program_completes(doc: &Document, fonts: &Dict, line: &TextLine, text: &str) -> bool {
|
||||
let Some(metrics) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|f| Metrics::from_dict(doc, &f)) else {
|
||||
return false;
|
||||
};
|
||||
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !source_encodes(&metrics, &c.to_string())).collect();
|
||||
!missing.is_empty() && source_program(doc, fonts, &line.font).is_some_and(|bytes| pdfcraft_fonts::embedding::covers(&bytes, &missing))
|
||||
}
|
||||
|
||||
/// A subset may contain all the letters but no space glyph: producers use another subset
|
||||
/// or a positioning operator for spaces. Do the same instead of substituting the whole font.
|
||||
pub(crate) fn source_encodes(metrics: &Metrics, text: &str) -> bool {
|
||||
text.split(' ').all(|word| metrics.encode(word).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops.
|
||||
pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub(crate) fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 {
|
||||
#[cfg(test)]
|
||||
SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::<f64>());
|
||||
if let Some(w) = width(metrics) {
|
||||
return w;
|
||||
}
|
||||
let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font);
|
||||
for (_, font) in fonts.iter() {
|
||||
if let Some(dict) = doc.resolve(font).as_dict() {
|
||||
let m = Metrics::from_dict(doc, dict);
|
||||
if m.base_font.rsplit('+').next() == Some(family)
|
||||
&& let Some(w) = width(&m)
|
||||
{
|
||||
return w;
|
||||
}
|
||||
}
|
||||
}
|
||||
0.33
|
||||
}
|
||||
|
||||
pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> f64 {
|
||||
if let Some(bytes) = metrics.encode(text) {
|
||||
// PDF word spacing applies only to single-byte code 32, even for a Unicode space.
|
||||
return metrics
|
||||
.codes(&bytes)
|
||||
.iter()
|
||||
.map(|(c, len)| metrics.width(*c) * size + char_spacing + if metrics.is_space(*c, *len) { word_spacing } else { 0.0 })
|
||||
.sum();
|
||||
}
|
||||
let words: f64 = text
|
||||
.split(' ')
|
||||
.filter_map(|w| metrics.encode(w))
|
||||
.map(|b| metrics.codes(&b).iter().map(|(c, _)| metrics.width(*c) * size + char_spacing).sum::<f64>())
|
||||
.sum();
|
||||
words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing)
|
||||
}
|
||||
|
||||
pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result<Vec<Op>, EditError> {
|
||||
if let Some(bytes) = metrics.encode(text) {
|
||||
return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]);
|
||||
}
|
||||
let adjustment = -1000.0 * (space + (char_spacing + word_spacing) / size);
|
||||
if !adjustment.is_finite() {
|
||||
return Err(EditError::Invalid("the source font's space advance is invalid".into()));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, word) in text.split(' ').enumerate() {
|
||||
if i > 0 {
|
||||
out.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
|
||||
}
|
||||
if !word.is_empty() {
|
||||
let bytes = metrics.encode(word).ok_or_else(|| EditError::Invalid("the source font cannot show the replacement text".into()))?;
|
||||
out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Loaded 100 of 589 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user