Merge upstream/main into fix/link-uri-base-and-quadpoints

Main moved a link's action reading into Inspector::action_target, shared
with bookmarks, and made the link/attachment text budget start with the
outline. Resolution:
- annotations.rs: link() calls action_target and then resolves a URI
  target against /URI /Base, spending what the base adds from the same
  budget (or keeping the raw address); QuadPoints reading unchanged.
  The budget doc comment covers bookmarks, links, quads and attachments.
- core.rs: keeps the branch's uri_base read; the outline goes through
  main's budgeted outline_window (the branch's old call is dropped).
- tools.rs: link_list keeps main's "to_page with its zoom" and the
  branch's resolved_url / active_areas text.
relative_uri_actions_resolve_against_the_catalog_base re-proven red with
the resolution reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410
2026-11-09 11:23:54 +08:00
co-authored by Claude Opus 5.5
492 changed files with 48670 additions and 3570 deletions
+5
View File
@@ -1,2 +1,7 @@
[alias]
xtask = "run -p xtask --"
# WebAssembly SIMD (every current browser has it; there's no runtime detection on wasm). The OCR
# engine's kernels (rten-simd) and other vectorised code only use it when compiled in.
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]
+8
View File
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# 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/release.yml@main
with:
@@ -27,6 +34,7 @@ jobs:
app-title: PdfCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+4 -3
View File
@@ -1,4 +1,4 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
<!-- craft-house-rules:start core source=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
@@ -19,7 +19,7 @@ These apply in every Craft repository. A later rule may be stricter, not weaker.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
<!-- craft-house-rules:start rust source=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=642f46b4ac38cc9e4d10d1ccde19d68cb6bc03d20e4c6236a7535f3047566262 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
@@ -40,6 +40,7 @@ Dependencies and assets use SPDX identifiers. An expression is allowed when ever
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `Unicode-3.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
@@ -83,7 +84,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
### 1.2 Only open, provable licences
Every asset must be one of:
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera.
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`).
- **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0.
- **Public domain.**
- **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`).
+7 -6
View File
@@ -45,7 +45,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -174,7 +174,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -184,7 +184,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs |
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
@@ -249,7 +249,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -267,7 +267,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | 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 |
## Compiled in through dependencies (6)
## Compiled in through dependencies (7)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -277,6 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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 |
| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
## Downloaded at build time (22)
@@ -314,4 +315,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-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales/<code>/common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English | The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6) | MIT OR Apache-2.0 | https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read) | https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md | Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels |
| `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 |
| `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); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; 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 |
+17 -5
View File
@@ -425,7 +425,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "201e790f3a05c65b2c4b0f952c9b9e1f35cd1c5b21698b3f4be131883fe90bc8"
[[asset]]
@@ -1844,7 +1844,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "55aa37e0b73e7f8cd906952dbdeb96283c6595ca64aba240a17f3455cb989114"
[[asset]]
@@ -1954,7 +1954,7 @@ source = "https://github.com/rsms/inter"
licence = "OFL-1.1"
licence_file = "assets/fonts/OFL-Inter.txt"
kind = "font"
usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths"
usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs"
sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82"
[[asset]]
@@ -2263,6 +2263,18 @@ adobe_data = true
usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1"
sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41"
[[bundled]]
crate = "icu_segmenter_data"
version = "2.3.0"
path = "data/segmenter_dictionary_extended_v1.rs.data"
title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)"
author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)"
source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)"
licence = "Unicode-3.0"
kind = "data"
usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one"
sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d"
[[fetched]]
file = "PlayfairDisplay[wght].ttf"
url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf"
@@ -3004,7 +3016,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472"
[[asset]]
@@ -3142,7 +3154,7 @@ source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/
licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font"
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); 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"
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); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; 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"
Generated
+83 -4
View File
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17"
dependencies = [
"craft-winprint-gdi",
"log",
@@ -1335,7 +1335,7 @@ dependencies = [
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
dependencies = [
"windows-sys 0.61.2",
]
@@ -3133,6 +3133,28 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -4265,7 +4287,6 @@ version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
@@ -4278,6 +4299,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-model",
"thiserror 2.0.21",
]
@@ -4400,6 +4422,7 @@ name = "pdfcraft-edit"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"icu_segmenter",
"kurbo 0.13.1",
"pdfcraft-annot",
"pdfcraft-content",
@@ -4540,10 +4563,22 @@ dependencies = [
"pdfcraft-fonts",
"png 0.18.1",
"rten",
"serde_json",
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-ocr-worker"
version = "0.18.1"
dependencies = [
"js-sys",
"pdfcraft-ocr",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "pdfcraft-optimize"
version = "0.18.1"
@@ -4557,6 +4592,7 @@ dependencies = [
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
"thread-priority",
"zune-jpeg",
]
@@ -4638,10 +4674,12 @@ dependencies = [
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-geom",
"pdfcraft-model",
"pdfcraft-render",
"pdfcraft-testkit",
"pdfcraft-xfa",
"regex",
"sha2 0.11.1",
"thiserror 2.0.21",
]
@@ -4660,6 +4698,8 @@ dependencies = [
"lopdf",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
@@ -4688,9 +4728,11 @@ dependencies = [
"p521",
"pbkdf2",
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-testkit",
"rc2",
"ripemd",
"rsa",
@@ -5521,6 +5563,29 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -6893,6 +6958,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "thread-priority"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "tiff"
version = "0.11.3"
@@ -8223,7 +8302,7 @@ dependencies = [
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
dependencies = [
"flate2",
"fmt-derive",
+9 -2
View File
@@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" }
# 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 = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
@@ -70,7 +70,11 @@ pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
hayro = "0.7.1"
hayro-syntax = "0.7.2"
lopdf = "0.45"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
# zlib-rs: miniz_oxide took about 1.7 µs for every fixed-Huffman block, empty or not
# (`pdfcraft_filters::FLATE_READ_WORK`). Other dependencies still build flate2's default
# miniz_oxide backend; flate2 prefers zlib-rs when both are built, as the Windows build already did
# through craft-update. `flate::tests::a_copy_from_before_the_start_is_damage` shows which runs.
flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] }
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
@@ -80,6 +84,9 @@ hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
+14 -1
View File
@@ -16,7 +16,7 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
@@ -60,6 +60,13 @@ destination = "licenses/CMap-LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt"
[[document]]
source = "licenses/ICU4X-LICENSE.txt"
licences = ["Unicode-3.0"]
destination = "licenses/ICU4X-LICENSE.txt"
sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2"
origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE"
[[document]]
source = "licenses/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
@@ -164,3 +171,9 @@ crate = "hayro-interpret"
path = "src/font/generated/metrics.rs"
licence = "MIT OR Apache-2.0"
documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"]
[[bundled]]
crate = "icu_segmenter_data"
path = "data/segmenter_dictionary_extended_v1.rs.data"
licence = "Unicode-3.0"
documents = ["licenses/ICU4X-LICENSE.txt"]
+29
View File
@@ -36,6 +36,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer
and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in
licenses/ICU4X-LICENSE.txt.
Required notices:
@@ -48,6 +51,32 @@ Required notices:
(https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of
Adobe nor the names of its contributors are used to endorse or promote this product.
ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets):
Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J:
ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any
associated documentation (the "Data Files") or software and any associated documentation (the
"Software") to deal in the Data Files or Software without restriction, including without
limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the
Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to
do so, provided that either (a) this copyright and permission notice appear with all copies of
the Data Files or Software, or (b) this copyright and permission notice appear in associated
Documentation.
THE DATA FILES AND SOFTWARE ARE 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 OF THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM,
OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF
USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall not be used in
advertising or otherwise to promote the sale, use or other dealings in these Data Files or
Software without prior written authorization of the copyright holder.
Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from
google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the
"Downloaded at build time" table in ATTRIBUTION.md.
+6
View File
@@ -499,6 +499,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
overrides are not supported.
To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the
welcome screen (type it, or Browse…) or pass it on the command line:
`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1`
on the command line for everyone). An upgrade keeps the folder the installed version uses unless
the command line gives another one.
**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
+51 -16
View File
@@ -1,8 +1,16 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
@@ -190,27 +198,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from
`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 97.4% | partial (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 (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 |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) |
| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 98.4% | partial |
| Portuguese (Brazil) | `pt-br` | 98.4% | partial |
| Indonesian | `id` | 97.5% | partial |
| Japanese | `ja` | 89.6% | partial |
| German | `de` | 89.7% | partial |
| Korean | `ko` | 97.5% | partial (no Hangul UI face) |
| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
(24 interface languages including English).
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian,
Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs**
(35 interface languages including English).
## Upcoming
@@ -229,20 +238,30 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way.
- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`.
- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
- **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged.
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
- **2026-11-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored.
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
- **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view.
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
@@ -256,6 +275,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
- **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged.
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
@@ -266,6 +286,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
- **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-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
@@ -277,6 +298,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
- **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-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed.
- **2026-10-24 (M10, `ocr.ocr-web`, review fix):** On the web, single-file and multiple-file OCR running at once no longer lose a page: the workers' answers go to the run that sent each page, instead of to whichever run collected first, and a cancelled run's late answers are let go. Regression tests cover both start orders and cancellation. Files chosen for Recognize Text in Multiple Files before the models have downloaded now wait for them instead of having to be chosen again, and idle workers close on time even when nothing is happening on the page, a full minute after their last page comes back rather than after it was handed out (checked by hand in Firefox); 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-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
@@ -315,6 +338,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
- **2026-10-20 (M9 #79 review, M2, M8):** text extraction reads a marked-content /ActualText that marks no glyph where it paints (paths and images, cut to what is visible, along the direction they are drawn), so words drawn as outlines can be found, copied and exported; OCR and scan field detection still count such a page as having no text of its own. Redaction strips /ActualText, /Alt, /E and /T from inline marked content around paint it clips, as it already did around paint it removed, so a partly redacted outlined word leaves no name in the file. Named-layer recognition also needs the layer form to draw n1 and n3. Estimates unchanged.
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
@@ -405,6 +429,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
- **2026-10-16 (M9, #79):** a signature appearance that follows the named-layer convention (its /N draws one Form that holds and draws n1 and n3) shows the signature's real state: n1, n3 and n4 are redrawn in the display copy with the panel's icons and a caption in the interface language (Helvetica; else shaped in embedded Inter, or in a craft-fonts face for Arabic, Japanese and Telugu; else English, as Chinese always is). Exports, both print modes and the CLI's render show the redrawn state; saves never do. Every signature keeps its badge, since a document can clip or cover its own layers. Estimates unchanged.
- **2026-10-16 (M9):** the page validation overlay moves to the widget's bottom-right corner as it lies on the page (so it turns with the page and the view) and takes a quarter of the widget's shorter side, at most 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field too small for a legible mark keeps its outline, a rectangle the screen cannot place is not drawn at all, and the whole widget answers the pointer. Estimates unchanged.
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
@@ -423,6 +449,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. 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-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
@@ -553,6 +580,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
@@ -788,11 +817,15 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
| 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-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. 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. |
@@ -807,6 +840,8 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-16 | minor | M9 #79: named-layer signature appearances show the signature's real state in the display copy, exports and both print modes, never in saves; captions follow the interface language. Estimates unchanged. |
| 2026-10-16 | minor | M9: the page validation overlay moves to the widget's bottom-right corner as it lies on the page and stops growing past 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field under 32 screen points keeps its outline. Estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
+38 -18
View File
@@ -8,6 +8,9 @@ pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
}
/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the
/// standards it declares, and the notes about damage repaired while opening it), with none of
/// its scripts run.
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")?;
@@ -18,24 +21,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
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();
// The document as the app opens it: the notes about damage repaired while opening, which the
// app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields.
// Inspection runs none of the document's scripts: an XFA form's initialize and calculate
// scripts, which the app runs on opening, are left alone, as with JavaScript turned off.
let mut session = pdfcraft_engine::Session::new();
session.set_javascript(false);
let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id));
// 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 })
})
let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| {
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();
});
// What the document declares as a standard, as the Standards panel reads it, and the notes
// Document Properties shows, which include what the app repaired after reading the objects (a
// page tree rebuilt from the pages found by scanning), both from the session's own reading. A
// file the app cannot open is read once more for what the object reader declares and repaired.
// A file that declares nothing leaves the standards null and empty.
let (declared, repairs) = match opened {
Some(d) => (d.standards(), d.repair_log()),
None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec()))
.unwrap_or_default(),
};
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -56,6 +68,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
"custom_properties": info.custom.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
"warnings": info.warnings,
"repairs": repairs,
});
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
Ok(())
@@ -366,8 +379,9 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
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.
// The app renders a display copy that draws comments missing their /AP streams and the state
// of named-layer signatures (here with no trusted certificates); 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
@@ -407,6 +421,12 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
let _ =
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
}
pdfcraft_render::RenderWarning::Type3WidthsMissing => {
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give"
);
}
}
}
Ok(())
@@ -0,0 +1,75 @@
//! `info` lists the notes about damage repaired while opening, as the app shows them in Document
//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a
//! page tree rebuilt from the pages found by scanning (a note the app adds after reading).
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and
/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`.
fn fixture(catalog: &str, pages: usize, bad_startxref: Option<usize>) -> Vec<u8> {
let content = "0 0 0 rg 10 10 40 20 re f";
let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect();
let mut objs = vec![
catalog.to_string(),
format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string()));
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 off in &offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
let start = bad_startxref.unwrap_or(xref);
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.pdf");
std::fs::write(&path, bytes).unwrap();
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
let _ = std::fs::remove_dir_all(&dir);
json
}
fn repairs(json: &serde_json::Value) -> Vec<String> {
json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect()
}
#[test]
fn info_lists_a_rebuilt_cross_reference_index() {
// `startxref` points into the middle of an object, so the index is rebuilt from the headers.
let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref");
assert_eq!(json["pages"], serde_json::json!(1), "{json}");
let lines = repairs(&json);
assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}");
}
#[test]
fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() {
// The catalog names a page tree object the file does not have (#1656).
let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree");
let lines = repairs(&json);
assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}");
}
#[test]
fn info_lists_no_repairs_for_a_sound_file() {
let json = info_of(&fixture(CATALOG, 1, None), "sound");
assert_eq!(json["repairs"], serde_json::json!([]), "{json}");
}
+1
View File
@@ -8,6 +8,7 @@ mod check;
mod edit;
mod extract;
mod help;
mod info_repairs;
mod info_standards;
mod mcp_compact;
mod options;
+41 -2
View File
@@ -7,6 +7,11 @@ use std::sync::Arc;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn fixture(test: &str) -> PathBuf {
fixture_with(test, "")
}
/// The two-page form, with `event` inside its `name` field.
fn fixture_with(test: &str, event: &str) -> PathBuf {
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
@@ -15,11 +20,12 @@ fn fixture(test: &str) -> PathBuf {
<medium short="200pt" long="300pt"/>
</pageArea></pageSet>
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value>{event}</field>
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
</subform>
</subform></template></xdp:xdp>"#;
</subform></template></xdp:xdp>"#
.replace("{event}", event);
let objects = [
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
@@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() {
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
}
/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening
/// and which here would change the field's value, does not run.
#[test]
fn info_runs_none_of_the_forms_scripts() {
let script = r#"<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Changed";</script></event>"#;
let path = fixture_with("info-script", script);
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let warnings = info["warnings"].as_array().unwrap();
assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}");
assert_eq!(info["repairs"], serde_json::json!([]), "{info}");
}
#[test]
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
let path = fixture("text");
@@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() {
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original);
}
/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it,
/// as Document Properties does after the form is laid out as one more revision.
#[test]
fn info_lists_the_repairs_of_a_laid_out_form() {
let path = fixture("info-damaged");
let bytes = std::fs::read(&path).unwrap();
let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let mut damaged = bytes[..at].to_vec();
damaged.extend_from_slice(b"20\n%%EOF\n");
std::fs::write(&path, &damaged).unwrap();
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let repairs = info["repairs"].as_array().unwrap();
assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}");
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pdfcraft-ocr-worker"
description = "PdfCraft in the browser: the Web Worker that recognises text (wasm32; trunk builds it with pdfcraft-web)"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
pdfcraft-ocr = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope", "MessageEvent"] }
+101
View File
@@ -0,0 +1,101 @@
//! PdfCraft in the browser: the Web Worker that recognises text, so reading a scanned page doesn't
//! stop the page responding and several pages are read at once. Trunk builds it alongside
//! `pdfcraft-web` (see apps/pdfcraft-web/index.html); the page starts and feeds the workers
//! (crates/ui-egui/src/workflows/ocr_ui.rs).
//!
//! Messages are plain objects with a `type`:
//!
//! - `ready` (to the page): the worker is listening; send the models now.
//! - `models` (from the page): `{ detection, recognition }`, each a `Uint8Array` holding a model
//! file. Answered with `loaded`, or `failed` with an `error`.
//! - `page` (from the page): `{ id, width, height, pixels }`, the RGBA pixels of a rendered page
//! as a `Uint8Array`. Answered with `lines` `{ id, lines }`, the recognised lines as
//! `pdfcraft_ocr::lines_to_json` writes them, or `failed` `{ id, error }`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
fn main() {
use wasm_bindgen::{JsCast, closure::Closure};
let Ok(scope) = js_sys::global().dyn_into::<web_sys::DedicatedWorkerGlobalScope>() else {
return;
};
let reply_to = scope.clone();
let mut ocr = None;
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
let _ = reply_to.post_message(&worker::handle(&mut ocr, &e.data()));
});
scope.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
// The handler lives as long as the worker.
on_message.forget();
let _ = scope.post_message(&worker::message(&[("type", "ready".into())]));
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-ocr-worker runs in a browser: trunk builds it with pdfcraft-web");
}
#[cfg(target_arch = "wasm32")]
mod worker {
use pdfcraft_ocr::Ocr;
use wasm_bindgen::{JsCast, JsValue};
/// The models are about 12 MB together.
const MAX_MODEL_BYTES: u32 = 64 << 20;
/// The renderer keeps pages far smaller; anything larger isn't a page.
const MAX_PIXEL_BYTES: u64 = 1 << 30;
pub fn message(fields: &[(&str, JsValue)]) -> JsValue {
let o = js_sys::Object::new();
for (k, v) in fields {
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
}
o.into()
}
/// Answer one message from the page.
pub fn handle(ocr: &mut Option<Ocr>, data: &JsValue) -> JsValue {
let get = |k: &str| js_sys::Reflect::get(data, &k.into()).unwrap_or(JsValue::UNDEFINED);
let id = get("id");
let reply = match get("type").as_string().as_deref() {
Some("models") => load(&get("detection"), &get("recognition")).map(|o| {
*ocr = Some(o);
message(&[("type", "loaded".into())])
}),
Some("page") => match ocr.as_ref() {
None => Err("the text recognition models aren't loaded".into()),
Some(o) => recognize(o, &get("width"), &get("height"), &get("pixels"))
.map(|lines| message(&[("type", "lines".into()), ("id", id.clone()), ("lines", lines.into())])),
},
_ => Err("unknown message".into()),
};
reply.unwrap_or_else(|error| message(&[("type", "failed".into()), ("id", id), ("error", error.into())]))
}
fn bytes(v: &JsValue, max: u64) -> Result<Vec<u8>, String> {
let a = v.dyn_ref::<js_sys::Uint8Array>().ok_or("expected a Uint8Array")?;
if u64::from(a.length()) > max {
return Err(format!("{} bytes is too large", a.length()));
}
Ok(a.to_vec())
}
fn load(detection: &JsValue, recognition: &JsValue) -> Result<Ocr, String> {
let max = u64::from(MAX_MODEL_BYTES);
Ocr::from_bytes(bytes(detection, max)?, bytes(recognition, max)?).map_err(|e| e.to_string())
}
fn recognize(ocr: &Ocr, width: &JsValue, height: &JsValue, pixels: &JsValue) -> Result<String, String> {
let side = |v: &JsValue| v.as_f64().filter(|v| v.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (Some(w), Some(h)) = (side(width), side(height)) else {
return Err("the page size is not a positive whole number".into());
};
let px = bytes(pixels, MAX_PIXEL_BYTES)?;
if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(4)) != Some(px.len() as u64) {
return Err("the pixels don't match the page size".into());
}
ocr.recognize(&px, w, h).map(|lines| pdfcraft_ocr::lines_to_json(&lines)).map_err(|e| e.to_string())
}
}
+7
View File
@@ -9,3 +9,10 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8766
# Trunk leaves the OCR worker's files unhashed and unnamed in index.html: hash them and name the
# worker for the page (xtask/src/web.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-worker-link"]
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PdfCraft</title>
<link data-trunk rel="rust" data-wasm-opt="z" />
<!-- Recognises text off the page's thread (crates/ui-egui/src/workflows/ocr_web.rs). -->
<link data-trunk rel="rust" href="../pdfcraft-ocr-worker/Cargo.toml" data-type="worker" data-loader-shim data-bindgen-target="web" data-wasm-opt="3" />
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #f1f1f3; }
canvas { width: 100%; height: 100%; display: block; outline: none; }
+1 -1
View File
@@ -48,7 +48,7 @@ fn main() {
});
}
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
// prompts; ask the browser to confirm instead while a document has unsaved work or an OCR review is open (#812).
warn_before_unload(app.unsaved_flag.clone());
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
+5 -18
View File
@@ -102,25 +102,12 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
/// is `num_days_from_ce` 719_163.
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_engine::civil_from_days`,
/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second
/// count over 86_400, at most about 2.1e14, so it always fits an `i64`.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
// The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`.
pdfcraft_engine::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
+1 -1
View File
@@ -333,7 +333,7 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
.with_min_inner_size([820.0, 520.0])
.with_min_inner_size(pdfcraft_ui_egui::MIN_WINDOW_SIZE)
.with_drag_and_drop(true)
// Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop.
.with_app_id(APP_ID);
+3 -2
View File
@@ -4,8 +4,9 @@
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, and
//! a file manager or `xdg-open` starts `pdfcraft <file>` the same way, so until now every file got
//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy,
//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft` (a portable copy's too, as
//! `portable-instance-…` and never in its own folder, #889) and is named after the Windows
//! session (`instance-s1.sock` for session 1), so two sign-ins of the same
//! user never open files in each other's windows. On Linux and the BSDs it is in
//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a
//! launch on another display or seat opens its own window there. Either way it lives in a folder
+5
View File
@@ -186,3 +186,8 @@ sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
-1
View File
@@ -14,6 +14,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true
+74 -20
View File
@@ -25,6 +25,8 @@ pub enum AltError {
NotAFigure(u32),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the figure's content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -52,6 +54,8 @@ pub enum ArtifactError {
ParentTree(String),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the tag's marked content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -372,7 +376,19 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
}
}
type DecodedStream = (Object, Option<(Stream, Vec<u8>)>);
/// How much of a content stream decoded.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Read {
Whole,
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
/// the syntax the later streams start in too.
Damaged,
/// Not at all (a filter this reader lacks, say): the data is empty.
Unread,
}
/// A `/Contents` entry and, for a stream, the stream, its decoded data and how much of it decoded.
type DecodedStream = (Object, Option<(Stream, Vec<u8>, Read)>);
/// Decode the page's content streams within the page decode budget. `None` when a stream is
/// rejected because the budget is exhausted.
@@ -383,14 +399,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>
for entry in entries {
let decoded = match &*doc.resolve(&entry) {
Object::Stream(s) => {
let data = match budget.decode(s) {
Ok(data) => data,
let (data, read) = match budget.decode_with_damage(doc, s, usize::MAX) {
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
// Content past the budget may hold more of the tag: editing the rest
// would leave that part marked as a tag the tree no longer has.
Err(_) if budget.exhausted() => return None,
Err(_) => Vec::new(),
Err(_) => (Vec::new(), Read::Unread),
};
Some((s.clone(), data))
Some((s.clone(), data, read))
}
_ => None,
};
@@ -403,6 +419,18 @@ fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &Hash
(op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
}
/// What [`artifact_marked_content`] did.
#[derive(Debug, PartialEq)]
enum Marked {
/// The streams holding the content were rewritten.
Done,
/// None of the content is on the page, or with `require_all` not all of it: nothing changed.
NotFound,
/// Some of it is, or may be, in a stream damaged partway or one that doesn't decode, neither of
/// which can be rewritten without losing what wasn't read, or after the damage: nothing changed.
Damaged,
}
/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
@@ -412,25 +440,45 @@ fn artifact_marked_content(
streams: Vec<DecodedStream>,
ids: &HashSet<i64>,
require_all: bool,
) -> Result<bool, pdfcraft_cos::CosError> {
) -> Result<Marked, pdfcraft_cos::CosError> {
let props = page_properties(doc, &page.dict);
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
// The pieces are the streams, in order.
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect();
let read: Vec<Read> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, _, read)| *read)).collect();
let damage = read.iter().position(|r| *r == Read::Damaged);
let pieces = pdfcraft_content::Pieces::join(&decoded);
let mut found = HashSet::new();
let mut edits = Vec::new();
for op in pieces.parse().ops {
if let Some(id) = marked_id(doc, &props, &op, ids) {
// What follows the damage is unknown: a string or comment the damage left open runs
// on into the next stream, so what reads as an operator after it may not be one. An
// operator whose operands start before a stream not read whole and whose keyword comes
// after it takes them from around what wasn't read: that stream may end them itself.
let (first, last) = pieces.pieces_of(&op);
if damage.is_some_and(|d| last > d) || read.get(first..last).is_some_and(|r| r.iter().any(|r| *r != Read::Whole)) {
return Ok(Marked::Damaged);
}
found.insert(id);
edits.push((op.span, b"/Artifact BMC".to_vec()));
}
}
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(false);
// Content not found may be past the damage, or in a stream that doesn't decode: whether it
// is on the page isn't known.
if found != *ids && read.iter().any(|r| *r != Read::Whole) {
return Ok(Marked::Damaged);
}
let mut spliced = pieces.splice(edits).into_iter();
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(Marked::NotFound);
}
let spliced = pieces.splice(edits);
if read.iter().zip(&decoded).zip(&spliced).any(|((read, old), new)| *read != Read::Whole && *old != new.as_slice()) {
return Ok(Marked::Damaged);
}
let mut spliced = spliced.into_iter();
let mut next = Vec::with_capacity(streams.len());
for (entry, stream) in streams {
let Some((stream, old)) = stream else {
let Some((stream, old, _)) = stream else {
next.push(entry);
continue;
};
@@ -456,7 +504,7 @@ fn artifact_marked_content(
_ => Object::Array(next),
};
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
Ok(true)
Ok(Marked::Done)
}
/// Decorative figure: its marked content on the page becomes an artifact, and the element
@@ -468,11 +516,15 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
// The parent tree no longer points those ids at the figure.
if artifact_marked_content(doc, page, streams, &ids, false)?
&& let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
{
clear_parent_tree(doc, key, obj)?;
match artifact_marked_content(doc, page, streams, &ids, false)? {
// The parent tree no longer points those ids at the figure.
Marked::Done => {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj)?;
}
}
Marked::NotFound => {}
Marked::Damaged => return Err(AltError::Damaged),
}
}
// Out of its parent's kids.
@@ -513,10 +565,12 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
let replacement = replacement_kids(doc, parent, obj)?;
let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
if !artifact_marked_content(doc, page, streams, &ids, true)? {
return Err(ArtifactError::ContentNotFound);
match artifact_marked_content(doc, page, streams, &ids, true)? {
Marked::Done => {}
Marked::NotFound => return Err(ArtifactError::ContentNotFound),
Marked::Damaged => return Err(ArtifactError::Damaged),
}
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
}
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
+17 -11
View File
@@ -4,7 +4,7 @@
//! which can then be retagged with the existing tag-property tool after human review.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
@@ -57,18 +57,24 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
return Err(unsupported(index, "decoding the content takes more than 16 MiB"));
}
let chain = stream.filters();
let decoded = if chain.is_empty() {
if stream.raw.len() > remaining {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
// Strictly, and with `/DecodeParms` given by reference: the content is written back from it.
let mut budget = DecodeBudget::new(remaining, 0);
let decoded = budget.decode_strict(doc, stream).map_err(|e| {
// The allowance counts what every filter stage decodes, as a page's does.
if budget.remaining() == 0 {
unsupported(index, "decoding the content takes more than 16 MiB")
} else {
// The decoder's own reason: `CosError::Filter`'s text would say again that the data
// couldn't be decoded.
let why = match e {
pdfcraft_cos::CosError::Filter(why) => why,
other => other.to_string(),
};
unsupported(index, format!("content cannot be decoded strictly: {why}"))
}
stream.raw.to_vec()
} else {
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
})?;
all.extend_from_slice(&decoded);
all.push(b' ');
}
+29 -4
View File
@@ -37,9 +37,16 @@ struct Walker<'a> {
visits: usize,
/// Per page: the text each MCID shows (read once per page).
texts: HashMap<usize, HashMap<i64, String>>,
/// What is left of [`MAX_TITLE_SOURCE_BYTES`].
title_left: usize,
out: Vec<Heading>,
}
/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all.
/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and
/// each would otherwise be decoded in full first.
const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20;
/// The headings, in document order. Headings without text or a page are left out.
pub fn headings(doc: &Document) -> Vec<Heading> {
let Some(root) = tree_root(doc) else { return Vec::new() };
@@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec<Heading> {
seen: HashSet::new(),
visits: 0,
texts: HashMap::new(),
title_left: MAX_TITLE_SOURCE_BYTES,
out: Vec::new(),
};
if let Some(k) = root.get(b"K") {
@@ -131,17 +139,34 @@ impl Walker<'_> {
self.marks(kids, pg, depth + 1, &mut marks);
}
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
let doc = self.doc;
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
let title = match attr(b"ActualText") {
let title = match self.attr(d, b"ActualText") {
Some(t) => Some(t),
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
None => match self.marked_text(&marks, page) {
Some(t) => Some(t),
None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")),
},
};
let Some(title) = title else { return };
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
self.out.push(Heading { obj, level, title, page });
}
/// The non-blank text of the string `key` holds, charged against the title allowance by the
/// longer of its raw and decoded size (a string that does not fit is not decoded).
fn attr(&mut self, d: &Dict, key: &[u8]) -> Option<String> {
let o = self.doc.resolve(d.get(key)?);
let s = o.as_string()?;
// A text string decodes to at most three bytes per raw byte, so one whose worst case does
// not fit the remaining allowance is never decoded: the raw check alone let a string at
// the raw limit decode to three times it before the allowance was reduced.
if s.bytes.len().saturating_mul(3) > self.title_left {
return None;
}
let text = s.to_text();
self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len()));
Some(text).filter(|t| !t.trim().is_empty())
}
/// The text the marked content shows (`page`: where marks without a page of their own are).
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
let mut parts = Vec::new();
+259
View File
@@ -79,6 +79,68 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
}
/// Page content behind a predictor whose `/DecodeParms` are given by reference is read through
/// them: autotagging writes the text back as it was, marked, not with the predictor's tag bytes in
/// it (a `\0` every eight bytes, which split `24` into `2 4`).
#[test]
fn autotag_text_reads_content_with_parameters_given_by_reference() {
use pdfcraft_cos::{Dict, ObjRef};
let mut content = b"BT /F1 24 Tf 72 700 Td (Hello World) Tj ET".to_vec();
content.resize(content.len().div_ceil(8) * 8, b' ');
// PNG rows of eight bytes, each tagged 0 (no prediction).
let rows: Vec<u8> = content.chunks(8).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
let parms: Dict = [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(8))].into_iter().collect();
let parms = doc.add(Object::Dict(parms));
let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
doc.set(ObjRef::new(4, 0), predicted);
assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 });
let page = &pdfcraft_model::pages(&doc)[0];
let written = doc.resolve(page.dict.get(b"Contents").unwrap());
let Object::Stream(written) = &*written else { panic!("page content") };
let text = String::from_utf8_lossy(&written.decoded_strict().unwrap()).into_owned();
assert!(text.contains("/F1 24 Tf") && text.contains("(Hello World) Tj"), "{text}");
}
/// Autotagging refuses a page whose content it can't read whole, and says why: content past
/// 16 MiB, or content that doesn't decode strictly, with the decoder's reason right after.
#[test]
fn autotag_text_says_why_it_cannot_read_a_page() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let reason = |content: Stream| {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
doc.set(ObjRef::new(4, 0), content);
match autotag_text(&mut doc) {
Err(AutoTagError::Unsupported { reason, .. }) => reason,
other => panic!("{other:?}"),
}
};
assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoding the content takes more than 16 MiB");
let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec());
corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let why = reason(corrupt);
assert!(why.starts_with("content cannot be decoded strictly: FlateDecode: "), "{why}");
}
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
let mut doc = pdf(
@@ -1087,6 +1149,185 @@ fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what
/// decoded would lose what follows the damage. Nothing changes, the structure tree and the stream
/// included. Content in a whole stream before a damaged one is marked, and the damaged one kept
/// as stored.
#[test]
fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() {
use pdfcraft_cos::{ObjRef, Object};
let content = b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n";
let damaged = damaged_flate(content, b"0 0 1 rg 0 0 10 10 re f");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged.clone());
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let state = |doc: &Document| (contents(doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)), doc.get(ObjRef::new(4, 0)));
let before = state(&doc);
assert_eq!(figures(&doc).len(), 2, "read up to the damage");
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(state(&doc), before);
let mut doc = figures_doc();
let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(other)])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
assert_ne!(list.first(), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten");
assert_eq!(list.get(1), Some(&Object::Ref(other)), "kept as stored");
}
/// What follows the damage is unknown, the syntax the next stream starts in too, so a tag's
/// content in a stream after a damaged one stays tagged. Here a comment the damage leaves open
/// runs on into the next stream, up to its first end of line, and a string starting there reads
/// as operators, among them what looks like the figure's content: rewritten, it would change the
/// string. Nothing changes, the structure tree included.
#[test]
fn a_tags_content_after_a_damaged_stream_is_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let in_a_string = b"(a note\n/Figure << /MCID 0 >> BDC 0 g EMC\n) pop /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC";
for (damaged, after) in [(b"0 g\n".as_slice(), None), (b"0 g % a comment".as_slice(), Some(in_a_string.as_slice()))] {
let mut doc = figures_doc();
if let Some(after) = after {
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), after.to_vec()));
}
let first = doc.add(damaged_flate(damaged, b"\n0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "{damaged:?}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "{damaged:?}");
assert_eq!(state(&doc), before, "{damaged:?}");
}
}
/// A page may split its content between an operator's operands and its keyword. When the operands
/// of the figure's `BDC` end the stream before one damaged from its start or one that doesn't
/// decode, the `BDC` the page reads is in what wasn't read: joined without it, those operands and
/// the next tag's read as one `BDC`, which reaches into the stream after it. Neither marking
/// changes anything, the structure tree included.
#[test]
fn a_tags_operands_before_a_stream_not_read_whole_are_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let rest = b" BDC q 40 0 0 20 10 150 cm /Im Do Q EMC";
for damaged in [true, false] {
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >>".to_vec()));
let hidden = if damaged {
damaged_flate(b"", rest)
} else {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), rest);
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
};
let hidden = doc.add(hidden);
let after = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC".to_vec())));
let listed = Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(after)]);
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed)).unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "damaged: {damaged}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "damaged: {damaged}");
assert_eq!(state(&doc), before, "damaged: {damaged}");
}
}
/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known
/// to be off the page, so neither marking changes anything, the structure tree included.
#[test]
fn a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(2))).unwrap();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"/Figure << /MCID 2 >> BDC 0 g EMC".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(state(&doc), before);
}
/// A tag's content not found before the damage in a damaged stream may be past it: it isn't known
/// to be off the page, so neither marking changes anything.
#[test]
fn a_tags_content_past_the_damage_is_not_taken_for_absent() {
use pdfcraft_cos::ObjRef;
let damaged =
damaged_flate(b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n", b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged);
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let before = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), before);
}
/// A page's `/StructParents` given by reference is read through it: the parent tree no longer
/// points the marked content at the tag.
#[test]
fn struct_parents_given_by_reference_are_read() {
use pdfcraft_cos::{ObjRef, Object};
let by_reference = || {
let mut doc = figures_doc();
let key = doc.add(Object::Int(0));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"StructParents".to_vec(), Object::Ref(key))).unwrap();
doc
};
let nums = |doc: &Document| doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
let mut doc = by_reference();
mark_decorative(&mut doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Ref(ObjRef::new(8, 0))])])));
let mut doc = by_reference();
mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Null])])));
}
/// A content stream that doesn't decode at all is kept as stored, and a tag in the page's other
/// streams is still marked: only running past the decode budget refuses.
#[test]
fn a_tag_beside_a_stream_that_does_not_decode_is_marked() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"0 g".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let contents = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let Some(Object::Array(list)) = contents else { panic!("contents {contents:?}") };
assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored");
}
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
@@ -1318,6 +1559,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
}
#[test]
fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() {
let count = 100;
let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::<Vec<_>>().join(" ");
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(),
stream("BT ET"),
"<< /Type /StructTreeRoot /K 6 0 R >>".to_string(),
format!("<< /S /Document /P 5 0 R /K [{kids}] >>"),
format!("({})", "a".repeat(512 << 10)),
];
objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string()));
let found = headings(&pdf(&objects, ""));
assert!(!found.is_empty() && found.len() < count, "{}", found.len());
}
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
+2
View File
@@ -11,6 +11,8 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
# The usable-quad rule `/QuadPoints` readers share.
pdfcraft-geom = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
+15 -7
View File
@@ -6,13 +6,19 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
- **Builders** for the comment types Acrobat's commenting tools create: sticky note (`Text` +
`Popup`), highlight, underline, strikethrough, squiggly, rectangle (`Square`), oval (`Circle`),
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`).
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`), text box and callout
(`FreeText`), polygon (including cloudy borders), polyline, caret, rubber and custom stamp,
and file attachment.
- **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
line breaking by Helvetica's standard-14 metrics. Thai in a text box, callout or Fill & Sign
text is shaped and drawn from the craft-fonts Thai face with Add text's writer
(`pdfcraft_fonts::shaped_text`: Type 3 glyphs with a ToUnicode map), the box sized to the ink
of its stacked marks and descenders; a build without a Thai face refuses it
(`AnnotError::Undrawable`) instead of drawing `?`. Other text it can't
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
@@ -43,8 +49,8 @@ deterministic; the engine supplies them.
- Unknown keys are kept. Inline annotation dictionaries are promoted to indirect objects only
when an edit needs a reference to them.
- `set_style` refuses (and changes nothing) when it cannot redraw the comment, e.g. stamps,
cloudy borders or callouts, instead of leaving a stale appearance.
- `set_style` refuses when it cannot redraw the comment, e.g. a stamp
whose appearance cannot be regenerated, instead of leaving a stale appearance.
- Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but
never paint a second icon.
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
@@ -59,7 +65,9 @@ deterministic; the engine supplies them.
(vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is
drawn.
## Not yet
## Rich text and remaining limits
Callouts, polygons and polylines, clouds, carets (insert/replace text), stamps, file
attachments, rich text (`/RC`, `/DS`), and the shared named-appearance cache Acrobat writes.
Text-box text and colour edits regenerate `/RC` (XHTML) and `/DS` from the plain
`/Contents`, `/DA` and `/Q`, so rich-text editors show the same text and style. These edits
replace imported rich-text formatting. The shared named-appearance cache Acrobat writes
is not implemented.
+94 -31
View File
@@ -1,20 +1,47 @@
//! Appearance streams (§12.5.5) for the comment types PdfCraft creates, execution plan M5.2.
//!
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary alone, so the
//! same code serves new comments and restyled ones. Drawings are in page space with
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the
//! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with
//! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box.
//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line
//! endings, indirect geometry), so callers never replace an appearance with a worse one.
//! endings), so callers never replace an appearance with a worse one.
//!
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3
//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses
//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances.
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use pdfcraft_fonts::shaped_text;
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with};
use crate::{NOTE_SIZE, Rgb, n};
/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai
/// face that text boxes draw it with.
pub fn text_width(text: &str, size: f64) -> f64 {
shaped_text::thai_width(text, size, helvetica_width)
}
/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter.
pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
wrap_with(text, size, width, text_width)
}
/// How far a text box's `lines` reach, in em: above the first baseline (0.9 of the 1.2 em line)
/// and below the last (0.3), or further where Thai stacked marks or descenders reach, so the box
/// is sized to their ink and doesn't clip them.
fn reach(lines: &[String]) -> [f64; 2] {
let ink = |line: Option<&String>| line.and_then(|l| shaped_text::thai_reach(l));
[ink(lines.first()).map_or(0.9, |r| r[1].max(0.9)), ink(lines.last()).map_or(0.3, |r| (-r[0]).max(0.3))]
}
/// The height a text box's `lines` take at `size`, inside its padding: 1.2 em a line, and more
/// for Thai that reaches further ([`reach`]).
pub fn text_lines_height(lines: &[String], size: f64) -> f64 {
let [up, down] = reach(lines);
lines.len().max(1) as f64 * size * 1.2 + (up - 0.9 + down - 0.3) * size
}
use crate::{Document, NOTE_SIZE, Rgb, n};
mod symbols;
@@ -166,11 +193,36 @@ fn nums(d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect()
}
/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK.
fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
/// The usable `/QuadPoints` quads, by the rule redaction apply reads them with
/// ([`pdfcraft_geom::quad_points::usable_quads`]): a malformed or non-finite quad is not drawn (it
/// would write `NaN` into the stream) and the others still are. The array and its entries are
/// resolved first, as the other readers resolve them, so an indirect quad is drawn too. `None`
/// when none is usable.
fn quads(context: &AppearanceContext<'_>) -> Option<Vec<[f64; 8]>> {
let doc = context.doc;
let q =
pdfcraft_geom::quad_points::usable_quads(doc.resolve(context.dict.get(b"QuadPoints")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()));
(!q.is_empty()).then_some(q)
}
/// `/C`-style colour arrays (ISO 32000-2 Table 166): none (transparent), gray, RGB or CMYK.
/// The entries should all be numbers; a four-entry array with a non-number in it, such as
/// `[1 0 0 null]`, is still read as CMYK with that entry 0, as PDFium, MuPDF and Poppler read it.
/// A reference is not such an entry: it stands for a value this builder can't see (§7.3.10), so
/// the colour is unreadable, as the other colour and number readers here treat one.
pub(crate) fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
let c = match d.get(key) {
None => return Some(None),
Some(o) => o.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
Some(o) => match o.as_array()? {
a if a.len() == 4 => a
.iter()
.map(|o| match o {
Object::Ref(_) => None,
o => Some(o.as_f64().unwrap_or(0.0)),
})
.collect::<Option<Vec<f64>>>()?,
a => a.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
},
};
Some(match c.as_slice() {
[] => None,
@@ -380,13 +432,15 @@ pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> {
/// 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 line's caption, a measurement's label, a
/// text of a text box, callout or Fill & Sign text (whose Thai the Thai face draws, where this
/// build has one), 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")?),
// A text box draws Thai too.
b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&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")?),
@@ -399,6 +453,11 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
pub(crate) fn helvetica_resource() -> Object {
Object::Dict(helvetica_fonts())
}
/// [`helvetica_resource`]'s dictionary.
fn helvetica_fonts() -> Dict {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
@@ -406,7 +465,7 @@ pub(crate) fn helvetica_resource() -> Object {
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
Object::Dict(fonts)
fonts
}
/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for
@@ -448,6 +507,7 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>)
#[derive(Clone, Copy)]
struct AppearanceContext<'a> {
doc: &'a Document,
dict: &'a Dict,
subtype: &'a [u8],
rect: [f64; 4],
@@ -484,8 +544,8 @@ impl AppearanceDrawing {
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option<Stream> {
let context = appearance_context(d)?;
pub fn build(doc: &Document, d: &Dict) -> Option<Stream> {
let context = appearance_context(doc, d)?;
let drawing = AppearanceDrawing::new(&context);
match context.subtype {
b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
@@ -499,7 +559,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
finish_appearance(&context, drawing)
}
fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option<AppearanceContext<'a>> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") {
@@ -514,6 +574,7 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
return None;
}
Some(AppearanceContext {
doc,
dict: d,
subtype,
rect,
@@ -561,13 +622,12 @@ fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDra
}
fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
let d = context.dict;
let subtype = context.subtype;
let stroke = context.stroke;
let c = &mut drawing.content;
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
let q = quads(context)?;
let col = stroke?;
for quad in q.as_chunks::<8>().0.iter() {
for quad in q.iter() {
let p = |i: usize| (quad[2 * i], quad[2 * i + 1]);
let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3));
let h = (p1.0 - p3.0).hypot(p1.1 - p3.1);
@@ -608,14 +668,13 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
}
fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
let d = context.dict;
let stroke = context.stroke;
let c = &mut drawing.content;
// While marked: the outline of each area (the fill comes when applied).
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
let q = quads(context)?;
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
for quad in q.as_chunks::<8>().0.iter() {
for quad in q.iter() {
c.push_str(&format!(
"{} {} m {} {} l {} {} l {} {} l h S\n",
n(quad[0]),
@@ -1090,27 +1149,31 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
));
let mut out = c.into_bytes();
let lines = wrap(&text, size, width);
let mut y = rect[3] - pad - size * 0.9;
let mut y = rect[3] - pad - size * reach(&lines)[0];
if round {
y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0);
y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0);
}
for line in lines {
// Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose
// streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as
// `?`, which `undrawable_text` reports), in Helvetica.
let mut fonts = helvetica_fonts();
let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts);
let mut current = None;
for pieces in &pieces {
if y < rect[1] - size {
break;
}
let lw = text_width(&line, size);
let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width);
let x = match q {
1 => rect[0] + pad + (width - lw) / 2.0,
2 => rect[2] - pad - lw,
_ => rect[0] + pad,
};
out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
out.extend(literal(&win_ansi(&line)));
out.extend_from_slice(b" Tj\n");
shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current);
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut stream = form(full, &out, res);
if let Some(m) = matrix {
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
+27 -1
View File
@@ -1,6 +1,6 @@
use super::draw::draw_appearance;
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box};
use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box};
use super::types::{
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
};
@@ -398,6 +398,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
}
let rect = fit_thai_ink(rect, turn, &new.contents, size, 2.0 + appearance::border_width(&d));
// An oval or a circle goes around the text box, its text wrapped as displayed:
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
shape_text_box(&mut d, rect, style.border_shape, style.width);
@@ -417,6 +418,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
refuse_undrawable_thai(d.name(b"Subtype") == Some(b"FreeText"), &new.contents)?;
let r = doc.add(Object::Dict(d.clone()));
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
@@ -427,6 +429,30 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
Ok(index)
}
/// Text box `tb` (user space; its text upright on a page turned by `turn`), grown down as displayed
/// until the ink of `text`'s Thai (stacked marks above its first line, descenders below its last)
/// fits inside the padding `pad`, as the appearance lays it out at `size`. Other text, and a box
/// tall enough already, keep the box they were given.
fn fit_thai_ink(tb: [f64; 4], turn: i64, text: &str, size: f64, pad: f64) -> [f64; 4] {
if !text.chars().any(pdfcraft_fonts::shaped_text::is_thai) {
return tb;
}
let [x0, y0, x1, y1] = normalize(tb);
let (w, h) = if matches!(turn, 90 | 270) { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) };
let need = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size) + 2.0 * pad + 2.0;
let by = need - h;
if !(by.is_finite() && by > 0.0) {
return tb;
}
// Displayed down is user -y unturned, +x on a page turned 90°, +y at 180° and -x at 270°.
match turn {
90 => [x0, y0, x1 + by, y1],
180 => [x0, y0, x1, y1 + by],
270 => [x0 - by, y0, x1, y1],
_ => [x0, y0 - by, x1, y1],
}
}
fn validate_style(style: &Style) -> Result<(), AnnotError> {
if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
+4 -2
View File
@@ -40,7 +40,7 @@ pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), Annot
/// 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)?;
let mut stream = appearance::build(doc, 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| {
@@ -57,9 +57,11 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option<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> {
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut 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);
// A text box's Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict);
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.
+40 -13
View File
@@ -105,7 +105,10 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
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]]);
let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
// Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry
// read as 0), like `props`; three numbers, some given by reference, are still read.
let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) };
if subtype == "FreeText"
&& let Some(da) = text_value(doc, d, b"DA")
{
@@ -117,7 +120,16 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
}
}
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
// Only usable quads, by the rule redaction apply reads them with, so a mark's outline
// is the area apply removes (and with none, the `/Rect` both fall back to).
let quads: Vec<[f32; 8]> = d
.get(b"QuadPoints")
.map(|o| doc.resolve(o))
.map(|a| {
let entries = a.as_array().into_iter().flatten().map(|x| doc.resolve(x).as_f64());
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
})
.unwrap_or_default();
// 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(),
@@ -235,8 +247,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
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 color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") };
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) }
@@ -252,7 +263,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
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(),
restylable: appearance::build(doc, d).is_some(),
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
fillable: fillable(&subtype),
fill: fill_of(d),
@@ -266,16 +277,32 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
})
}
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. 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,
};
drawn_color(d, key)
}
/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference.
/// Only an array of at most four entries is copied out of the reference.
fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> {
let rgb = match d.get(b"C") {
Some(o @ Object::Ref(_)) => {
let held = doc.resolve(o);
let entries = held.as_array().filter(|a| a.len() <= 4)?;
let mut direct = Dict::new();
direct.set(b"C".to_vec(), Object::Array(entries.clone()));
drawn_color(&direct, b"C")
}
_ => drawn_color(d, b"C"),
}?;
Some(rgb.map(|x| x as f32))
}
/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are
/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0.
fn drawn_color(d: &Dict, key: &[u8]) -> Option<Rgb> {
let rgb = appearance::color(d, key).flatten()?;
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
}
+118 -13
View File
@@ -8,13 +8,77 @@ use crate::{AnnotError, annots, page_ref, page_refs, set_annots};
/// Where a link goes.
#[derive(Clone, Debug, PartialEq)]
pub enum LinkAction {
/// A page of this document (0-based), shown fitting the window (`/Fit`).
Page(usize),
/// A page of this document (0-based), shown at `zoom`.
Page {
page: usize,
zoom: LinkZoom,
},
Uri(String),
/// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is.
Other(String),
}
/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an
/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the
/// reader keeps its position in that direction, as a new bookmark does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LinkZoom {
/// `/Fit`: the whole page in the window.
FitPage,
/// `/FitH null`: the page's width across the window.
FitWidth,
/// `/FitV null`: the page's height down the window.
FitHeight,
/// `/FitB`: the page's content (its bounding box) in the window.
FitVisible,
/// `/XYZ null null null`: the reader's zoom is kept.
Inherit,
/// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written
/// within 1–6400%; a link rewritten with the zoom it has keeps its own factor.
Percent(f64),
}
impl LinkZoom {
/// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The
/// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown
/// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom
/// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its
/// zoom is written (`write_action`) but not its position.
fn of_dest(doc: &Document, dest: &[Object]) -> Self {
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec));
match kind.as_deref() {
Some(b"FitB") => LinkZoom::FitVisible,
Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth,
Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight,
Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) {
// Finite even for a factor near f64::MAX. A link given a new page with this zoom
// keeps the factor itself, however far outside 1–6400% (`write_action`).
Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)),
// A zoom of 0 or null keeps the reader's (Table 149).
_ => LinkZoom::Inherit,
},
_ => LinkZoom::FitPage,
}
}
/// The destination's elements after the page reference.
fn operands(self) -> Result<Vec<Object>, AnnotError> {
Ok(match self {
LinkZoom::FitPage => vec![Object::name("Fit")],
LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null],
LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null],
LinkZoom::FitVisible => vec![Object::name("FitB")],
LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null],
LinkZoom::Percent(p) => {
if !(p.is_finite() && p > 0.0) {
return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into()));
}
vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))]
}
})
}
}
/// Acrobat's Link Properties ▸ Appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LinkStyle {
@@ -87,17 +151,18 @@ fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
}
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
let dest_page = |dest: &Object| -> Option<usize> {
let page_view = |dest: &Object| -> Option<LinkAction> {
let a = doc.resolve(dest);
let first = a.as_array()?.first()?.clone();
match first {
Object::Ref(r) => pages.iter().position(|p| *p == r),
Object::Int(i) => usize::try_from(i).ok(),
let a = a.as_array()?;
let page = match a.first()? {
Object::Ref(r) => pages.iter().position(|p| p == r),
Object::Int(i) => usize::try_from(*i).ok(),
_ => None,
}
}?;
Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) })
};
if let Some(dest) = d.get(b"Dest") {
return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page);
return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into()));
}
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
match a.name(b"S") {
@@ -115,7 +180,7 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize)
None => LinkAction::Other("web address too long".into()),
}
}
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
Some(b"GoTo") => a.get(b"D").and_then(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())),
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
None => LinkAction::Other("no action".into()),
}
@@ -214,12 +279,52 @@ fn uri_bytes(u: &str) -> Vec<u8> {
out
}
/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`.
fn current_dest(doc: &Document, d: &Dict) -> Option<Vec<Object>> {
let dest = match d.get(b"Dest") {
Some(dest) => doc.resolve(dest),
None => {
let action = doc.resolve(d.get(b"A")?);
let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?;
doc.resolve(go_to.get(b"D")?)
}
};
dest.as_array().cloned()
}
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
match a {
LinkAction::Page(p) => {
let target = page_ref(doc, *p)?;
LinkAction::Page { page, zoom } => {
let target = page_ref(doc, *page)?;
let mut operands = zoom.operands()?;
let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom);
if let Some(c) = current {
// The page and zoom the link has (`link_edit` naming its own page): the
// destination is left as it is, position on the page and GoTo form included.
let same_page = match c.first() {
Some(Object::Ref(r)) => *r == target,
Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page),
_ => false,
};
if same_page {
return Ok(());
}
// A new page with the zoom the link has keeps how that zoom is written: a boxed
// fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400%
// a new zoom is written within. Positions belong to the old page and are dropped.
let (slot, own) = match zoom {
LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)),
LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)),
_ => (None, None),
};
if let (Some(slot), Some(own)) = (slot, own) {
*slot = doc.resolve(own).as_ref().clone();
}
}
let mut dest = vec![Object::Ref(target)];
dest.extend(operands);
d.remove(b"A");
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")]));
d.set(b"Dest".to_vec(), Object::Array(dest));
}
LinkAction::Uri(u) => {
let u = u.trim();
+100 -17
View File
@@ -25,6 +25,15 @@ pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotE
Ok(())
}
/// Thai typed into a text box (`free_text`) that this build can't draw (it has no Thai face): refused
/// with what to do, rather than drawn as `?`.
pub(crate) fn refuse_undrawable_thai(free_text: bool, text: &str) -> Result<(), AnnotError> {
match free_text.then(|| pdfcraft_fonts::shaped_text::thai_refusal(text)).flatten() {
Some(why) => Err(AnnotError::Undrawable(why)),
None => 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 });
@@ -85,7 +94,7 @@ pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotE
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
let doomed = with_replies_and_popups(doc, &list, seed(target));
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
// answers either. Pop-ups follow their comment.
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
@@ -106,15 +115,28 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let doomed = with_replies_and_popups(doc, &list, seed(target));
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
Ok(set_annots(doc, p, kept)?)
}
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
/// comment (which has none to collect) the pop-up it names, which still goes.
fn seed(target: &Object) -> Option<ObjRef> {
target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")))
/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a
/// Replace Text proposal also takes the caret it is grouped under, so the two go together
/// whichever is deleted.
fn seed(doc: &Document, target: &Object) -> Vec<ObjRef> {
let own = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
own.into_iter().chain(replaced_by(doc, target)).collect()
}
/// The caret a Replace Text strikeout is grouped under (`/IT /StrikeOutTextEdit`, `/RT /Group`,
/// `/IRT` to a caret): the proposal's primary, which holds the replacement.
fn replaced_by(doc: &Document, strike: &Object) -> Option<ObjRef> {
let obj = doc.resolve(strike);
let d = obj.as_dict()?;
let edit = d.name(b"Subtype") == Some(b"StrikeOut") && d.name(b"IT") == Some(b"StrikeOutTextEdit") && d.name(b"RT") == Some(b"Group");
let caret = d.reference(b"IRT").filter(|_| edit)?;
(annot_dict(doc, caret).name(b"Subtype") == Some(b"Caret")).then_some(caret)
}
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
@@ -165,6 +187,7 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
let free_text = d.name(b"Subtype") == Some(b"FreeText");
refuse_undrawable_thai(free_text, text)?;
let snug = free_text && is_snug(&d);
doc.update_dict(r, |d| {
d.set(b"Contents".to_vec(), PdfString::text(text));
@@ -224,8 +247,8 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
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])
let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size);
Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top])
}
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
@@ -459,7 +482,7 @@ fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] {
let (_, size) = appearance::parse_da(d);
let lines = appearance::wrap(&text, size, wrap.max(1.0));
let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
[widest, lines.len().max(1) as f64 * size * 1.2]
[widest, appearance::text_lines_height(&lines, size)]
}
/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by
@@ -703,14 +726,66 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
/// redraw it.
///
/// A Replace Text proposal is one look: colour and opacity are the group's (`/C` is read from
/// the primary, ISO 32000-2 §12.5.6.2), so restyling either the caret or its strikeout restyles
/// and redraws the other too, in the same edit. The strikeout takes no line width (a text markup
/// annotation has no border) and neither part has line endings. All or nothing: if one member
/// can't be restyled, none is.
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
let StyleChange { color, opacity, width, endings } = change;
let (_, r) = annot_ref(doc, page, index)?;
let others = replace_text_partners(doc, page, r)?;
// Check every partner before changing anything.
for &o in &others {
unlocked(doc, o)?;
}
let before: Vec<(ObjRef, Dict)> = others.iter().map(|&o| (o, annot_dict(doc, o))).collect();
let mut done: Vec<(ObjRef, Dict)> = vec![(r, annot_dict(doc, r))];
style_one(doc, r, change.clone(), meta)?;
for (o, d) in before {
let caret = d.name(b"Subtype") == Some(b"Caret");
let part = StyleChange { color: change.color, opacity: change.opacity, width: change.width.filter(|_| caret), endings: None };
let unchanged = part.color.is_none() && part.opacity.is_none() && part.width.is_none();
if unchanged {
continue;
}
if let Err(e) = style_one(doc, o, part, meta) {
// Put the members already restyled back, the target included.
for (o, d) in done.into_iter().rev() {
doc.set(o, Object::Dict(d));
}
return Err(e);
}
done.push((o, d));
}
Ok(())
}
/// The other members of the Replace Text proposal `r` belongs to, on `page`: for its caret, the
/// strikeouts grouped under it; for a strikeout, its caret and any other strikeout under it.
/// Empty for anything else.
fn replace_text_partners(doc: &Document, page: usize, r: ObjRef) -> Result<Vec<ObjRef>, AnnotError> {
let own = Object::Ref(r);
let caret = match replaced_by(doc, &own) {
Some(c) => c,
None if annot_dict(doc, r).name(b"Subtype") == Some(b"Caret") => r,
None => return Ok(Vec::new()),
};
let list = annots(doc, page_ref(doc, page)?);
let strikes = list.iter().filter(|e| replaced_by(doc, e) == Some(caret)).filter_map(Object::as_ref);
// `/Annots` may list one annotation twice: restyle each once.
let mut seen: HashSet<ObjRef> = HashSet::from([r]);
Ok(std::iter::once(caret).chain(strikes).filter(|&o| seen.insert(o)).collect())
}
/// [`set_style`] on one annotation.
fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
let StyleChange { color, opacity, width, endings } = change;
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() {
if appearance::build(doc, &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()) {
@@ -871,7 +946,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
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() {
if appearance::build(doc, &d).is_none() {
return Err(AnnotError::Unsupported(subtype));
}
if fill.is_some_and(|c| !finite(&c)) {
@@ -912,7 +987,7 @@ pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Opt
return Err(AnnotError::Invalid("invalid opacity".into()));
}
// Check before changing anything: a stale appearance would contradict the new opacity.
if appearance::build(&d).is_none() {
if appearance::build(doc, &d).is_none() {
return Err(AnnotError::Unsupported(subtype));
}
refuse_foreign_text(doc, &d)?;
@@ -984,8 +1059,13 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
}
/// 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.
/// struck text holding `replacement`, the two grouped so they move, list and delete as one.
/// Returns the strikeout's index.
///
/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to
/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary,
/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has
/// to be on the primary.
///
/// 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
@@ -1006,7 +1086,6 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
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]];
@@ -1021,10 +1100,14 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
)?;
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"IT".to_vec(), Object::name("Replace"));
d.set(b"Subj".to_vec(), PdfString::text("Replace Text"));
})?;
doc.update_dict(sr, |d| {
d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit"));
d.set(b"IRT".to_vec(), Object::Ref(cr));
d.set(b"RT".to_vec(), Object::name("Group"));
})?;
Ok(s)
}
+493 -22
View File
@@ -1003,12 +1003,12 @@ fn stamps_draw_their_label_and_by_line() {
#[test]
fn links_are_added_edited_listed_and_removed() {
use crate::links::{self, Highlight, LinkAction, LinkStyle};
use crate::links::{self, Highlight, LinkAction, LinkStyle, LinkZoom};
let mut doc = fixture();
let before = links::list(&doc).len();
let i = links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap();
let style = LinkStyle { visible: true, color: [1.0, 0.0, 0.0], width: 2.0, dashed: true, underline: false, highlight: Highlight::Outline };
links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page(0), &style).unwrap();
links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &style).unwrap();
let doc = reopen(&doc);
let all = links::list(&doc);
assert_eq!(all.len(), before + 2);
@@ -1017,7 +1017,7 @@ fn links_are_added_edited_listed_and_removed() {
(web.action.clone(), web.style.visible, web.style.highlight),
(LinkAction::Uri("https://example.org".into()), false, Highlight::Invert)
);
let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page(0)).unwrap().clone();
let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }).unwrap().clone();
assert_eq!(page.style, style);
let mut doc = doc;
links::set(&mut doc, 1, page.index, Some([0.0, 0.0, 50.0, 20.0]), Some(&LinkAction::Uri("mailto:ada@example.org".into())), None).unwrap();
@@ -1027,7 +1027,201 @@ fn links_are_added_edited_listed_and_removed() {
assert!(links::delete(&mut doc, 0, 999).is_err(), "no such annotation");
let n = links::remove_all(&mut doc, None).unwrap();
assert!(n >= 1 && links::list(&doc).is_empty());
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
assert!(
links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &LinkStyle::default()).is_err(),
"too small"
);
}
#[test]
fn page_links_keep_their_zoom() {
use crate::links::{self, LinkAction, LinkStyle, LinkZoom};
let page1 = page_refs(&fixture()).unwrap()[1];
let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned());
// Each zoom is written as its destination type (Table 149) and reads back after a save.
let null = Object::Null;
for (zoom, written) in [
(LinkZoom::FitPage, vec![Object::name("Fit")]),
(LinkZoom::FitWidth, vec![Object::name("FitH"), null.clone()]),
(LinkZoom::FitHeight, vec![Object::name("FitV"), null.clone()]),
(LinkZoom::FitVisible, vec![Object::name("FitB")]),
(LinkZoom::Inherit, vec![Object::name("XYZ"), null.clone(), null.clone(), null.clone()]),
(LinkZoom::Percent(100.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(1.0)]),
(LinkZoom::Percent(250.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(2.5)]),
] {
let mut doc = fixture();
let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom }, &LinkStyle::default()).unwrap();
let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap();
let mut expected = vec![Object::Ref(page1)];
expected.extend(written);
assert_eq!(dest_of(&doc, link.obj.unwrap()), Some(Object::Array(expected)), "{zoom:?}");
let doc = reopen(&doc);
let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap();
assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{zoom:?} after a save");
}
// What other writers use reads as the nearest zoom: a zero zoom keeps the reader's, the boxed
// fits as the plain ones, a rectangle as Fit Page, and a GoTo action like a /Dest.
let mut doc = fixture();
let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap();
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
let dest = |kind: &str, operands: Vec<Object>| {
let mut d = vec![Object::Ref(page1), Object::name(kind)];
d.extend(operands);
Object::Array(d)
};
for (key, value, zoom) in [
("Dest", dest("XYZ", vec![Object::Int(100), Object::Int(700), Object::Int(0)]), LinkZoom::Inherit),
("Dest", dest("FitBH", vec![Object::Int(500)]), LinkZoom::FitWidth),
("Dest", dest("FitBV", vec![Object::Int(20)]), LinkZoom::FitHeight),
("Dest", dest("FitR", vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)]), LinkZoom::FitPage),
("A", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1.5)]), LinkZoom::Percent(150.0)),
// A factor outside what PdfCraft writes reads as the destination has it, not capped, so
// it agrees with the viewer's `destination`; one whose percentage overflows stays finite.
("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(100.0)]), LinkZoom::Percent(10000.0)),
("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(0.005)]), LinkZoom::Percent(0.5)),
("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1e307)]), LinkZoom::Percent(f64::MAX)),
] {
let mut d = doc.get(obj).as_dict().cloned().unwrap();
d.remove(b"Dest");
d.remove(b"A");
if key == "A" {
let mut go_to = pdfcraft_cos::Dict::new();
go_to.set(b"S".to_vec(), Object::name("GoTo"));
go_to.set(b"D".to_vec(), value);
d.set(b"A".to_vec(), Object::Dict(go_to));
} else {
d.set(b"Dest".to_vec(), value);
}
doc.set(obj, Object::Dict(d));
let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap();
assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{key}");
}
// A zoom that isn't a positive percentage is refused before anything is written.
for p in [0.0, -50.0, f64::NAN, f64::INFINITY] {
let before = links::list(&doc).len();
let refused =
links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(p) }, &LinkStyle::default());
assert!(refused.is_err(), "{p}");
assert_eq!(links::list(&doc).len(), before, "{p}: no link added");
}
}
/// A link rewritten with the zoom it has keeps its own `/XYZ` factor, however far outside the
/// 1–6400% a new zoom is written within: giving it a new page (Link Properties' OK, `link_edit`
/// without `zoom`) must not change its zoom. A zoom chosen anew is still written within 1–6400%.
#[test]
fn a_rewritten_link_keeps_its_own_zoom_factor() {
use crate::links::{self, LinkAction, LinkStyle, LinkZoom};
let pages = page_refs(&fixture()).unwrap();
let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned());
let xyz = |page: ObjRef, factor: Object| Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, factor]);
for (key, factor) in [
("Dest", Object::Real(0.005)),
("Dest", Object::Real(100.0)),
("Dest", Object::Int(100)),
("Dest", Object::Real(1e307)),
("A", Object::Real(0.005)),
] {
let mut doc = fixture();
let i =
links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap();
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
let mut d = doc.get(obj).as_dict().cloned().unwrap();
d.remove(b"Dest");
if key == "A" {
let mut go_to = pdfcraft_cos::Dict::new();
go_to.set(b"S".to_vec(), Object::name("GoTo"));
go_to.set(b"D".to_vec(), xyz(pages[1], factor.clone()));
d.set(b"A".to_vec(), Object::Dict(go_to));
} else {
d.set(b"Dest".to_vec(), xyz(pages[1], factor.clone()));
}
doc.set(obj, Object::Dict(d));
let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else {
panic!("{key} {factor:?}")
};
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap();
assert_eq!(dest_of(&doc, obj), Some(xyz(pages[0], factor.clone())), "{key} {factor:?}: the factor is the link's own");
let doc = reopen(&doc);
assert_eq!(
links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action,
LinkAction::Page { page: 0, zoom },
"{key} {factor:?}"
);
}
// A zoom other than the link's is written within 1–6400%.
let mut doc = fixture();
let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(50.0) }, &LinkStyle::default())
.unwrap();
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 1, zoom: LinkZoom::Percent(10000.0) }), None).unwrap();
assert_eq!(dest_of(&doc, obj), Some(xyz(pages[1], Object::Real(64.0))));
}
/// A go-to edited without a change (its own page and zoom, as `link_edit` naming its page does)
/// is left as it is: the `/XYZ` position, a fit's position and the GoTo form stay. Given a new
/// page with its zoom, a link keeps how that zoom is written, a boxed fit (`/FitBH`, `/FitBV`)
/// included, but not its position, which belongs to the old page.
#[test]
fn an_unchanged_go_to_keeps_its_destination_and_a_new_page_keeps_its_fit() {
use crate::links::{self, LinkAction, LinkStyle, LinkZoom};
let pages = page_refs(&fixture()).unwrap();
let dest = |page: ObjRef, kind: &str, operands: Vec<Object>| {
let mut d = vec![Object::Ref(page), Object::name(kind)];
d.extend(operands);
Object::Array(d)
};
let link_with = |key: &str, value: Object| {
let mut doc = fixture();
let i =
links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap();
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
let mut d = doc.get(obj).as_dict().cloned().unwrap();
d.remove(b"Dest");
if key == "A" {
let mut go_to = pdfcraft_cos::Dict::new();
go_to.set(b"S".to_vec(), Object::name("GoTo"));
go_to.set(b"D".to_vec(), value);
d.set(b"A".to_vec(), Object::Dict(go_to));
} else {
d.set(b"Dest".to_vec(), value);
}
doc.set(obj, Object::Dict(d));
(doc, i, obj)
};
let (num, null) = (|n: i64| Object::Int(n), Object::Null);
// The same page and zoom: nothing is rewritten.
for (key, value) in [
("Dest", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)])),
("Dest", dest(pages[1], "FitBH", vec![num(250)])),
("Dest", dest(pages[1], "FitV", vec![num(20)])),
("A", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(0.005)])),
] {
let (mut doc, i, obj) = link_with(key, value);
let before = doc.get(obj).as_dict().cloned();
let LinkAction::Page { page, zoom } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else {
panic!("{key}")
};
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page, zoom }), None).unwrap();
assert_eq!(doc.get(obj).as_dict().cloned(), before, "{key} {zoom:?}: left as it is");
}
// A new page: the fit's kind and the factor stay, positions go.
for (value, written) in [
(dest(pages[1], "FitBH", vec![num(250)]), dest(pages[0], "FitBH", vec![null.clone()])),
(dest(pages[1], "FitBV", vec![num(20)]), dest(pages[0], "FitBV", vec![null.clone()])),
(dest(pages[1], "FitH", vec![num(250)]), dest(pages[0], "FitH", vec![null.clone()])),
(
dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)]),
dest(pages[0], "XYZ", vec![null.clone(), null.clone(), Object::Real(2.0)]),
),
] {
let (mut doc, i, obj) = link_with("Dest", value.clone());
let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else {
panic!("{value:?}")
};
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap();
assert_eq!(doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()), Some(written), "{value:?}");
}
}
#[test]
@@ -1264,22 +1458,36 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let strike = &a[i];
assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..])));
let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..])));
assert!(caret.reference(b"IRT").is_some());
// The caret is the group's primary: a group's text is read from its primary and the
// subordinates' Contents are ignored (ISO 32000-2 §12.5.6.2), so the replacement is on it.
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"IT")), ("new words", Some(&b"Replace"[..])));
assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none(), "a primary has no /IRT: {caret:?}");
assert_eq!(strike.name(b"RT"), Some(&b"Group"[..]));
assert!(text(strike, b"Contents").is_empty());
// #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it
// replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the
// pair does not survive an export and import.
assert_eq!(text(caret, b"NM").as_str(), "r-caret");
assert_eq!(text(strike, b"NM").as_str(), "r");
assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name");
let irt = caret.reference(b"IRT").unwrap();
assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout");
let irt = strike.reference(b"IRT").unwrap();
assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped under the caret");
let r = rect(caret);
assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}");
// Deleting the strikeout takes the caret with it (the group).
// Deleting either one takes the other with it (the proposal is one comment).
let marks = |doc: &Document| list(doc, 0).iter().filter(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))).count();
let mut by_strike = doc.clone();
delete_annotation(&mut by_strike, 0, i).unwrap();
assert_eq!(marks(&by_strike), 0, "deleting the strikeout deletes the caret");
let mut doc = doc;
delete_annotation(&mut doc, 0, i).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
// A locked caret keeps the strikeout too: the pair can't be broken by deleting the other.
let mut locked = doc.clone();
set_locked(&mut locked, 0, c, true).unwrap();
assert!(deletable(&locked, 0, i).is_err() && delete_annotation(&mut locked, 0, i).is_err(), "a locked caret refuses its strikeout's deletion");
assert_eq!(marks(&locked), 2);
delete_annotation(&mut doc, 0, c).unwrap();
assert_eq!(marks(&doc), 0, "deleting the caret deletes the strikeout");
assert!(
add_text_replacement(
&mut doc,
@@ -1298,6 +1506,55 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
);
}
/// Review on #5689 (CodeRabbit): clicking a Replace Text strikeout selects its caret, the
/// group's primary, so a restyle from Comment Properties lands on the caret. Colour and opacity
/// are the proposal's, so the strikeout is restyled and redrawn in the same edit, whichever
/// member is restyled; it takes no border width. A locked member refuses the whole change.
#[test]
fn restyling_a_replace_text_pair_restyles_both_members() {
let mut doc = fixture();
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
let tr = TextReplacement {
page: 0,
quads: &quads,
replacement: "new words",
author: "Ada",
strike: &red,
caret: &red,
strike_meta: &meta("r"),
caret_meta: &meta("r-caret"),
};
let s = add_text_replacement(&mut doc, tr).unwrap();
let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
let color = |doc: &Document, i: usize| {
list(doc, 0)[i].get(b"C").and_then(|o| o.as_array().cloned()).unwrap().iter().filter_map(Object::as_f64).collect::<Vec<_>>()
};
let green = [0.0, 0.5, 0.0];
// Restyled through the caret (what a click on either part selects).
let mut by_caret = doc.clone();
set_style(&mut by_caret, 0, c, StyleChange::new(Some(green), Some(0.5), Some(3.0), None), &meta("")).unwrap();
let by_caret = reopen(&by_caret);
assert_eq!(color(&by_caret, c), green.to_vec());
assert_eq!(color(&by_caret, s), green.to_vec(), "the strikeout takes the proposal's colour");
let strike = &list(&by_caret, 0)[s];
assert_eq!(strike.get(b"CA").and_then(Object::as_f64), Some(0.5), "and its opacity");
assert!(strike.get(b"BS").is_none(), "a strikeout has no border to widen: {strike:?}");
assert!(ap_content(&by_caret, strike).contains("0 0.5 0 RG"), "its appearance is redrawn: {}", ap_content(&by_caret, strike));
// Restyled through the strikeout: the caret follows.
let mut by_strike = doc.clone();
set_style(&mut by_strike, 0, s, StyleChange::new(Some(green), None, None, None), &meta("")).unwrap();
assert_eq!(color(&by_strike, c), green.to_vec(), "the caret takes the proposal's colour");
// A locked strikeout refuses the change, and nothing moves.
let mut locked = doc.clone();
set_locked(&mut locked, 0, s, true).unwrap();
assert!(set_style(&mut locked, 0, c, StyleChange::new(Some(green), None, None, None), &meta("")).is_err());
assert_eq!(color(&locked, c), red.color.to_vec(), "the caret keeps its colour");
}
#[test]
fn files_attach_as_comments() {
let mut doc = fixture();
@@ -1350,6 +1607,12 @@ fn the_eraser_cuts_strokes_and_removes_empty_drawings() {
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Ink")));
}
/// A document holding only a catalog, for appearance builds from a bare dictionary whose
/// values are all direct.
fn bare_doc() -> Document {
document_of(&[b"<< /Type /Catalog /Pages << /Type /Pages /Kids [] /Count 0 >> >>"])
}
/// A one-page document from object bodies (object 1 is the catalog, 3 the page).
fn document_of(objs: &[&[u8]]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
@@ -1445,7 +1708,7 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
polyline.set(b"IC".to_vec(), malformed);
for (name, annotation) in [("Square", square), ("Circle", circle), ("Polygon", polygon), ("Line", line), ("PolyLine", polyline)] {
assert!(appearance::build(&annotation).is_some(), "malformed optional /IC must not hide a valid {name} stroke");
assert!(appearance::build(&bare_doc(), &annotation).is_some(), "malformed optional /IC must not hide a valid {name} stroke");
}
let doc = document_of(&[
@@ -1540,7 +1803,7 @@ fn a_stamp_without_an_appearance_draws_the_stamp_it_names() {
// A stamp's own artwork is never replaced, and the drawing is for display only: edits
// (restyling, flattening) still find nothing they may write.
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none());
assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none());
assert!(appearance::build(&bare_doc(), &stamp_dict("/Name /Draft")).is_none());
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`");
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width");
}
@@ -1643,8 +1906,8 @@ fn every_line_ending_is_drawn() {
assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice()));
assert!(ap_content(&doc, call).contains("h B"), "diamond callout");
assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
assert!(appearance::build(&bare_doc(), &line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
assert!(appearance::build(&bare_doc(), &line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
}
#[test]
@@ -2185,7 +2448,7 @@ fn note_drawing(name: Option<&str>, color: [f64; 3]) -> Vec<u8> {
if let Some(n) = name {
d.set(b"Name".to_vec(), Object::name(n));
}
crate::appearance::build(&d).expect("a note is drawn").decoded().unwrap()
crate::appearance::build(&bare_doc(), &d).expect("a note is drawn").decoded().unwrap()
}
/// ISO 32000-2 §12.5.6.4, Table 175: a viewer shall provide icons for the seven standard names,
@@ -2233,7 +2496,7 @@ fn a_star_note_keeps_its_star_when_recoloured() {
fn content_of(src: &str) -> String {
let doc = document_of(&[src.as_bytes()]);
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary");
let stream = appearance::build(&d).expect("drawn");
let stream = appearance::build(&bare_doc(), &d).expect("drawn");
String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned()
}
@@ -2285,7 +2548,7 @@ fn bad_dash_arrays_draw_solid() {
for w in [f64::NAN, f64::INFINITY, -2.0] {
let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec());
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash]));
let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned();
let c = String::from_utf8_lossy(&appearance::build(&bare_doc(), &d).unwrap().decoded().unwrap()).into_owned();
assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}");
}
}
@@ -2307,7 +2570,7 @@ fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
let caret = |extra: &str| {
let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]);
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
let s = appearance::build(&d).unwrap();
let s = appearance::build(&bare_doc(), &d).unwrap();
(s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font")))
};
let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T");
@@ -2335,7 +2598,7 @@ fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
}
let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]);
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
assert!(appearance::build(&d).is_some());
assert!(appearance::build(&bare_doc(), &d).is_some());
}
// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ──────────
@@ -2355,7 +2618,7 @@ fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict {
/// The built appearance's content.
fn line_drawing(d: &Dict) -> String {
let s = crate::appearance::build(d).expect("the line is drawn");
let s = crate::appearance::build(&bare_doc(), d).expect("the line is drawn");
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
@@ -2508,7 +2771,7 @@ fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() {
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 &amp; <b>5</b>0&#x20;mm &lt;x&gt;</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();
let s = crate::appearance::build(&bare_doc(), &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", '長')));
@@ -2779,3 +3042,211 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
check(&doc);
check(&reopen(&doc));
}
/// PDFBOX-2699: a `/C` written `[1 0 0 null]` (Table 166 asks for numbers) is still a four-entry
/// colour, CMYK with the null as 0 as PDFium, MuPDF and Poppler read it, so a shape without an
/// appearance is drawn instead of dropped. `/IC` is read the same way, and three numbers stay RGB.
/// The properties and the comment list report the colours as drawn.
#[test]
fn a_four_entry_colour_with_a_null_is_cmyk_with_zero() {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 null] /IC [0 0 1 null] >>",
b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>",
]);
let display = with_missing_appearances(&doc).expect("both squares get a fallback appearance");
let squares = list(&display, 0);
let cmyk = ap_content(&display, &squares[0]);
assert!(cmyk.contains("0 1 1 RG") && cmyk.contains("1 1 0 rg"), "CMYK (1,0,0,0) stroke and (0,0,1,0) fill: {cmyk}");
let rgb = ap_content(&display, &squares[1]);
assert!(rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}");
// The properties read the stroke and the fill as they are drawn.
let p = props(&doc, 0, 0).expect("the square has properties");
assert_eq!((p.color, p.fill), (Some([0.0, 1.0, 1.0]), Some([1.0, 1.0, 0.0])));
assert_eq!(props(&doc, 0, 1).and_then(|p| p.color), Some([1.0, 0.0, 0.0]));
// The comment list reports the same colours.
let colors: Vec<Option<[f32; 3]>> = summaries(&doc).iter().map(|s| s.color).collect();
assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([1.0, 0.0, 0.0])]);
}
/// A `/C` given by reference is followed for the comment list, as three numbers were, and read
/// like a direct one: `[0 0 1 null]` is CMYK yellow, and a referenced grey level is grey.
#[test]
fn a_referenced_colour_is_listed_like_a_direct_one() {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C 6 0 R >>",
b"<< /Type /Annot /Subtype /Circle /Rect [55 5 95 35] /C 7 0 R >>",
b"[0 0 1 null]",
b"[0.5]",
]);
let colors: Vec<Option<[f32; 3]>> = summaries(&doc).iter().map(|s| s.color).collect();
assert_eq!(colors, [Some([1.0, 1.0, 0.0]), Some([0.5, 0.5, 0.5])]);
}
/// A reference among the four entries is not a non-number read as 0: `/C [1 0 0 6 0 R]` with
/// object 6 the number 1 is CMYK black (§7.3.10), so drawing it cyan would be wrong. The builder
/// doesn't follow it and draws no appearance, as for any colour it can't read.
#[test]
fn a_four_entry_colour_with_a_reference_is_not_read_as_zero() {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 6 0 R] >>",
b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>",
b"1",
]);
let display = with_missing_appearances(&doc).expect("the RGB square gets a fallback appearance");
let squares = list(&display, 0);
assert!(!squares[0].contains(b"AP"), "no cyan stand-in for CMYK black");
assert_eq!(props(&doc, 0, 0).and_then(|p| p.color), None);
assert_eq!(summaries(&doc)[0].color, None, "nor in the comment list");
}
/// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits.
fn baselines(content: &str) -> Vec<f64> {
content.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect()
}
/// Thai typed into Fill & Sign text or a text box is shaped and drawn from the Thai face, as Add
/// text draws it: Type 3 glyphs (their streams objects of their own) with a ToUnicode map, never
/// `?`. The box grows to hold the ink of its stacked marks and descenders, which reach past the
/// 1.2 em lines boxes are sized by. A build without a Thai face refuses the text, saying why.
#[test]
fn thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face() {
let mut doc = fixture();
// Each line has the face's highest stack (น้ำ) and deepest descender (ฎู).
let thai = "น้ำฎู บุญ\nปั้นน้ำฎู";
let item = |shape: Shape| NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: thai.into(), author: "Ada".into() };
let shapes = [
Shape::Typewriter { rect: [100.0, 700.0, 300.0, 716.0], font_size: 24.0 },
Shape::TextBox { rect: [100.0, 400.0, 400.0, 430.0], font_size: 30.0 },
];
if pdfcraft_fonts::document_thai_font().is_none() {
for shape in shapes {
let err = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}");
}
assert_eq!(list(&doc, 0).len(), 1, "nothing was added");
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 }), &meta("b")).unwrap();
assert!(matches!(set_contents(&mut doc, 0, t, thai, &meta("")), Err(AnnotError::Undrawable(_))));
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "hello");
return;
}
let reach = pdfcraft_fonts::shaped_text::thai_reach;
let (lo, hi) = (reach("ฎู").unwrap()[0], reach("น้ำ").unwrap()[1]);
for (shape, size) in shapes.into_iter().zip([24.0, 30.0]) {
let t = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[t];
assert_eq!((text(d, b"Contents").as_str(), pdfcraft_fonts::undrawn_mark(d)), (thai, None));
let content = ap_content(&doc, d);
assert!(!content.contains('?') && content.contains("/PCTh1 "), "{content}");
// The glyphs and the text they stand for are objects of the saved file.
let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).unwrap();
let ap = doc.get(n);
let fonts = ap.as_dict().and_then(|s| s.get(b"Resources")).and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict());
let font = fonts.and_then(|f| f.get(b"PCTh1")).and_then(|f| f.as_dict()).unwrap().clone();
assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..]));
let cmap = doc.get(font.reference(b"ToUnicode").unwrap());
let Object::Stream(cmap) = &*cmap else { panic!("ToUnicode is a stream object") };
let cmap = String::from_utf8_lossy(&cmap.decoded().unwrap()).into_owned();
assert!(cmap.contains("<0E190E490E33>"), "น้ำ copies as typed: {cmap}");
let procs = font.get(b"CharProcs").and_then(|p| p.as_dict()).unwrap();
assert!(procs.iter().all(|(_, g)| matches!(g, Object::Ref(_))), "glyph programs are stream objects");
// Every line's ink lies inside the box: the appearance's BBox and its clip.
let (r, ys) = (rect(d), baselines(&content));
let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min));
assert!(top + hi * size <= r[3] + 1e-3 && bottom + lo * size >= r[1] - 1e-3, "{ys:?} at {size} in {r:?}");
}
// New text refits the box to its ink too: the stack on its first line, the descender on its last.
set_contents(&mut doc, 0, 1, "น้ำ\nฎู", &meta("")).unwrap();
let d = &list(&doc, 0)[1];
let (r, ys) = (rect(d), baselines(&ap_content(&doc, d)));
assert!(ys[0] + hi * 24.0 <= r[3] + 1e-3 && ys[ys.len() - 1] + lo * 24.0 >= r[1] - 1e-3, "{ys:?} in {r:?}");
}
const QUAD: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0];
fn reals(v: &[f64]) -> Vec<Object> {
v.iter().map(|x| Object::Real(*x)).collect()
}
/// The appearance a `subtype` with `/QuadPoints` `quads` draws, as text.
fn quad_appearance(subtype: &str, quads: Vec<Object>) -> Option<String> {
quad_appearance_with(&bare_doc(), subtype, Object::Array(quads))
}
fn quad_appearance_with(doc: &Document, subtype: &str, quad_points: Object) -> Option<String> {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name(subtype));
d.set(b"Rect".to_vec(), Object::Array([20, 95, 40, 110].map(Object::Int).to_vec()));
d.set(b"C".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)]));
d.set(b"QuadPoints".to_vec(), quad_points);
appearance::build(doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
}
/// Appearance drawing reads `/QuadPoints` by the rule the other readers do, indirect objects
/// included: entries by reference, and the array itself by reference, draw the same quads as
/// the direct values.
#[test]
fn appearances_draw_indirect_quads_like_direct_ones() {
for subtype in ["Redact", "Highlight"] {
let clean = quad_appearance(subtype, reals(&QUAD)).unwrap();
let mut doc = bare_doc();
let by_ref: Vec<Object> = QUAD.iter().map(|v| Object::Ref(doc.add(Object::Real(*v)))).collect();
assert_eq!(quad_appearance_with(&doc, subtype, Object::Array(by_ref)).as_deref(), Some(clean.as_str()), "{subtype}: entries by reference");
let array_ref = Object::Ref(doc.add(Object::Array(reals(&QUAD))));
assert_eq!(quad_appearance_with(&doc, subtype, array_ref).as_deref(), Some(clean.as_str()), "{subtype}: the array by reference");
}
}
/// Redaction outlines and text markup draw the quads redaction apply would use, no more: a NaN
/// quad wrote `NaN` into the stream, and one non-number or a trailing partial quad left the other,
/// usable quads undrawn.
#[test]
fn appearances_draw_only_usable_quads() {
let mut one_nan = QUAD;
one_nan[3] = f64::NAN;
for subtype in ["Redact", "Highlight"] {
let clean = quad_appearance(subtype, reals(&QUAD)).unwrap();
assert!(!clean.contains("NaN"), "{clean}");
let mut name_first = reals(&[QUAD, QUAD].concat());
name_first[2] = Object::name("oops");
let same =
[reals(&[one_nan, QUAD].concat()), name_first, reals(&[&QUAD[..], &QUAD[..4]].concat()), reals(&[QUAD, [f64::INFINITY; 8]].concat())];
for (i, quads) in same.into_iter().enumerate() {
assert_eq!(quad_appearance(subtype, quads).as_deref(), Some(clean.as_str()), "{subtype} case {i}");
}
// No usable quad draws nothing, as with no /QuadPoints.
assert_eq!(quad_appearance(subtype, reals(&[f64::NAN; 8])), None, "{subtype}");
}
}
/// The comment list reads `/QuadPoints` by the rule redaction apply does: unusable quads are left
/// out, not read as zeros, and an indirect number is followed.
#[test]
fn summaries_list_only_usable_quads() {
let mut doc = fixture();
let forty = Object::Ref(doc.add(Object::Int(40)));
let page = page_refs(&doc).unwrap()[0];
let mut entries = annots(&doc, page);
let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
// A non-number in the first quad, an indirect 40 in the second, NaN in the third, and four
// entries of a fourth.
let mut quads = reals(&[QUAD, QUAD, QUAD, QUAD].concat());
quads[1] = Object::name("oops");
quads[10] = forty;
quads[17] = Object::Real(f64::NAN);
quads.truncate(28);
annotation.set(b"Subtype".to_vec(), Object::name("Redact"));
annotation.set(b"QuadPoints".to_vec(), Object::Array(quads));
set_annots(&mut doc, page, entries).unwrap();
let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap();
assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]);
}
+1 -1
View File
@@ -3,7 +3,7 @@
Layer L4 (may use `edit`): verification tooling, the faux-redaction audit.
```rust
let findings: Vec<Finding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
let findings: Vec<AuditFinding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
```
It reports two kinds of finding:
+2 -2
View File
@@ -283,7 +283,7 @@ impl<'a> Walker<'a> {
return Err(AuditError::TooManyForms);
}
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = self.budget.decode_strict(stream)?;
let data = self.budget.decode_strict(self.doc, stream)?;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
sub_gs.ctm = matrix.then(&gs.ctm);
@@ -326,7 +326,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(budget.decode_strict(s)?);
streams.push(budget.decode_strict(doc, s)?);
}
}
let pieces = Pieces::join(&streams);
+48 -3
View File
@@ -61,7 +61,8 @@ do not keep an old PDF byte buffer alive.
This bounds retained text-cache data, not total process memory: active extraction batches,
renderers, document buffers and the requested output have separate lifetimes. Search still reads
growing batches (8 to 128 pages), with one lookahead match to determine continuation.
growing batches (8 to 128 pages), with one lookahead match to determine continuation, unless
`stop_at_limit` is set (below), which starts at one page and looks no further than its limit.
`text_find` keeps matches from readable pages when another reached page fails extraction. It
returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and
@@ -75,6 +76,16 @@ that reaches the end with warnings therefore has `complete: false` **without** `
should follow `next` when present and separately inspect warnings. A capped search can return
both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged.
That lookahead means a search with exactly `limit` matches reads on to the end of the document to
learn that no further match exists. `stop_at_limit: true` skips it: the search ends at the
`limit`th match. It reads one page first, then batches of 2, 4, … up to 128 pages, so nothing past
the batch holding that match is read or can add warnings (a match on the first page reads only that
page). The answer carries
`stopped_at_limit`. When that is `true`, `complete` is `false` and `next` is only the position just
past the last match, not a promise of another one; following it may return no matches and
`complete: true`. When it is `false`, the search ran out of pages first and `complete` means what
it always does.
`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed
semantics; they never silently act on partial text.
@@ -146,7 +157,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
and fonts. A refusal changes nothing; success is one undo step. See
and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the
page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every
resource they use and `object_paste {doc, page}` pastes them as page content that draws the same,
into the same or another open document, stepped as the app steps a paste. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
@@ -209,6 +223,30 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
and [image editing](../../docs/editing-images.md).
`text_fonts` also lists `builtin:thai` when the build includes the bundled Noto Sans Thai face
(the optional craft-fonts input). Passing it as `text_edit.font_resource` explicitly replaces a
paragraph or range with that face; the source font is never swapped for it silently. A build
without the face never lists it, and `text_edit` refuses it there. A page whose own fonts fill
the listing's 32 MiB preview budget can leave it out too; `text_edit` still accepts it then.
### Preparing grouped text
`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as one click
on grouped text in the GUI's Edit text does. It keeps the group's original glyph operators and shared source
objects, records preparation and map repairs in the repair log, and is one undoable step. List
`text_paragraphs` afterwards; its indices belong to the prepared page. Besides the document
summary it returns what this edit did:
| Field | Type | Meaning |
| --- | --- | --- |
| `prepared_groups` | integer | Form placements drawn in place, so their text is now the page's own. |
| `repaired_fonts` | integer | Page fonts whose damaged legacy THSarabunPSK Thai map was repaired on a page-owned copy. |
| `refused_groups` | array of strings | One `name: reason` per group left as it was: unsafe compositing, tags, malformed content, or text grouped more than eight levels deep. |
The page's own fonts with that damaged map are repaired too, whether their text was grouped or
not. It fails, changing nothing, when the page has nothing to prepare or repair (the error gives
the first reason a group was left as it was) or the page is out of range.
### Superscript and subscript normalization
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
@@ -261,7 +299,14 @@ Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
The model is framed to fill the image and shaded by a headlight on white. U3D models are refused
The model is framed to fill the image and shaded by a headlight on white. `orbit`, instead of
`direction`, frames it as a viewer's orbit control would: `{view, up, front, turn, zoom,
zoom_at, pan}`, all optional. `view` is `iso` (default), `front`, `right`, `top` or `back`
about the `up` axis (default `+z`) and the `front` axis (perpendicular to `up`); `turn` is
`[yaw, pitch]` in degrees, pitch down; `zoom` is a positive factor, the result held within 0.05 to 50, at `zoom_at` `[right, up]`
in picture heights from the centre, where the point under it stays still; `pan` `[right, up]`
moves the target, in model radii, so the model moves the other way. Every view frames the model
at the same size, whatever its direction. U3D models are refused
with an error, and the annotation's own views, lighting and render modes are not applied yet.
## Viewer navigation history
+1 -1
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
mod documents;
mod form_tools;
+2 -13
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
impl Automation {
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
@@ -148,18 +148,7 @@ impl Automation {
.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)?;
let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?;
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
}
Err(e) => json!({ "path": p, "error": e }),
+50 -3
View File
@@ -185,6 +185,7 @@ impl Automation {
let recipe = Recipe::parse(self, args)?;
let recursive = args.opt_bool("recursive")?.unwrap_or(false);
let stop = args.opt_bool("stop_on_error")?.unwrap_or(false);
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
let paths = args.strs("paths")?;
if paths.is_empty() || paths.len() > 1000 {
return Err(Args::wrong("paths", "1 to 1000 files or folders"));
@@ -211,11 +212,41 @@ impl Automation {
let mut files = discovery.skipped;
let (mut completed, mut failed_count) = (0, 0);
let mut stopped = false;
let mut planned_outputs: HashSet<PathBuf> = HashSet::new();
for (path, relative) in discovery.files {
if stopped || !recipe.accepts(&path) {
files.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":if stopped {"stopped after error"} else {"unsupported input extension"}}));
continue;
}
if dry_run {
let planned: Result<PathBuf> = (|| {
let target = out.join(&relative).with_extension("pdf");
let target = self.resolve(target.to_str().ok_or_else(|| failed("output path is not UTF-8"))?, true)?;
if !target.starts_with(&out) {
return Err(failed("output path leaves out_dir"));
}
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
let stem = target.file_stem().ok_or_else(|| failed("output has no stem"))?.to_string_lossy().into_owned();
for n in 1..=10_000 {
let candidate = if n == 1 { target.clone() } else { dir.join(format!("{stem} ({n}).pdf")) };
if candidate.symlink_metadata().is_ok() || planned_outputs.contains(&candidate) {
continue;
}
planned_outputs.insert(candidate.clone());
return Ok(candidate);
}
Err(failed("all 10000 output names are occupied"))
})();
match planned {
Ok(target) => files.push(json!({"path":path.to_string_lossy(),"status":"planned","output":target.to_string_lossy().replace('\\', "/"),"steps":recipe.steps})),
Err(e) => {
failed_count += 1;
files.push(json!({"path":path.to_string_lossy(),"status":"error","error":e.to_string()}));
stopped = stop;
}
}
continue;
}
// Recheck confinement and links before reading a discovered file.
let result: Result<_> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let current = self.resolve(path.to_str().ok_or_else(|| failed("input path is not UTF-8"))?, false)?;
@@ -244,13 +275,13 @@ impl Automation {
}
}
}
let skipped = files.len().saturating_sub(completed + failed_count);
Ok(json!({"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
let skipped = files.iter().filter(|file| file["status"] == "skipped").count();
Ok(json!({"dry_run":dry_run,"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
}
}
/// Publish atomically without replacing any existing name, even if another writer wins a race.
fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
pub(crate) fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
std::fs::create_dir_all(dir).map_err(failed)?;
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
@@ -279,4 +310,20 @@ mod tests {
discovery.visited = 0;
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, MAX_DEPTH + 1).unwrap_err().to_string().contains("64"));
}
#[test]
fn doc_batch_dry_run_lists_outputs_without_writing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let pdf = Session::new().create_from_text("Doc", "Hello").unwrap();
std::fs::write(dir.path().join("input/a.pdf"), &*pdf).unwrap();
let auto = Automation::new().with_root(dir.path()).unwrap();
let report = auto.doc_batch(&Args(&json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","dry_run":true}))).unwrap();
assert_eq!(report["dry_run"], true, "{report}");
assert_eq!(report["completed"], 0, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(report["files"][0]["status"], "planned", "{report}");
assert!(report["files"][0]["output"].as_str().unwrap().ends_with("out/a.pdf"), "{report}");
assert!(!dir.path().join("out").exists());
}
}
+243 -25
View File
@@ -8,6 +8,8 @@ use super::json::Args;
use super::paths::read_input;
use super::{Arc, Automation, Path, PathBuf};
const MAX_BATES_BYTES: u64 = 512 * 1024 * 1024;
struct FilePlan {
input: String,
source: PathBuf,
@@ -26,19 +28,23 @@ impl Automation {
}
let start = match args.0.get("start_number") {
None | Some(Value::Null) => 1,
Some(value) => value
.as_u64()
.filter(|n| *n >= 1)
.ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?,
Some(value) => {
value.as_u64().filter(|n| *n >= 1).ok_or_else(|| ToolError::InvalidArgs("start_number must be an integer of at least 1".into()))?
}
};
let digits = args.opt_int("digits")?.unwrap_or(6);
let digits =
u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?;
let prefix = args.opt_str("prefix")?.unwrap_or("");
let suffix = args.opt_str("suffix")?.unwrap_or("");
// `#` separates the token's fields and `>>` ends it: either would cut the token short.
if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) {
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into()));
if prefix.chars().count() > 128
|| suffix.chars().count() > 128
|| prefix.contains('#')
|| suffix.contains('#')
|| prefix.contains(">>")
|| suffix.contains(">>")
{
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 characters and cannot contain # or >>".into()));
}
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
@@ -70,16 +76,7 @@ impl Automation {
targets.push((output.to_string(), target));
passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned));
}
let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect();
let mut output_paths = std::collections::HashSet::new();
for (name, path) in &targets {
if input_paths.contains(path) {
return Err(failed(format!("output {name} names an input file; choose a separate output path")));
}
if !output_paths.insert(path.clone()) {
return Err(failed(format!("two files use the same output path: {name}")));
}
}
check_unique_outputs(&targets, dry_run)?;
let mut plans = Vec::with_capacity(files.len());
let mut next = start;
@@ -88,13 +85,13 @@ impl Automation {
let size = std::fs::metadata(input_path).map_err(failed)?.len();
input_bytes = input_bytes
.checked_add(size)
.filter(|total| *total <= 512 * 1024 * 1024)
.filter(|total| *total <= MAX_BATES_BYTES)
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
let bytes = read_input(input_path)?;
input_bytes = input_bytes
.saturating_sub(size)
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
.filter(|total| *total <= 512 * 1024 * 1024)
.filter(|total| *total <= MAX_BATES_BYTES)
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let password = passwords.get(index).and_then(Option::as_deref);
@@ -106,7 +103,8 @@ impl Automation {
return Err(failed(format!("{input_name} has no pages to number")));
}
let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?;
let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
let last_offset = count.checked_sub(1).ok_or_else(|| failed(format!("{input_name} has no pages to number")))?;
let last = next.checked_add(last_offset).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
plans.push(FilePlan {
input: input_name.clone(),
source: input_path.clone(),
@@ -116,7 +114,7 @@ impl Automation {
first: next,
last,
});
if index + 1 < sources.len() {
if index + 1 < files.len() {
next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?;
}
}
@@ -163,12 +161,14 @@ impl Automation {
let result = applied.and_then(|()| self.session.save_full_bytes(id));
match result {
Ok(bytes) => {
let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
if total > 512 * 1024 * 1024 {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
} else {
let candidate = output_bytes
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
.filter(|total| *total <= MAX_BATES_BYTES);
if let Some(total) = candidate {
output_bytes = total;
staged.push((plan.output.clone(), bytes));
} else {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
}
}
Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})),
@@ -198,6 +198,123 @@ impl Automation {
}
}
fn check_unique_outputs(targets: &[(String, PathBuf)], dry_run: bool) -> Result<()> {
if targets.len() < 2 {
return Ok(());
}
// Probe exact basenames in a directory under each canonical parent, so the filesystem applies its own name rules.
let mut groups: std::collections::HashMap<PathBuf, (PathBuf, Vec<(String, std::ffi::OsString)>)> = std::collections::HashMap::new();
for (name, target) in targets {
let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {name}")))?;
let identity = parent.canonicalize().map_err(failed)?;
let probe_name = output_probe_name(target).map_err(failed)?;
groups.entry(identity.clone()).or_insert_with(|| (identity, Vec::new())).1.push((name.clone(), probe_name));
}
if dry_run {
// Dry runs cannot probe the volume: Windows uses a conservative case-insensitive comparison,
// while Unix preserves case, so unusual case-sensitive Windows or case-insensitive Unix volumes can differ.
for (_, (_, names)) in groups {
for (index, (name, candidate)) in names.iter().enumerate() {
if names.iter().take(index).any(|(_, prior)| same_output_name(prior, candidate)) {
return Err(failed(format!("two files use the same output path: {name}")));
}
}
}
return Ok(());
}
for (_, (parent, names)) in groups {
match tempfile::tempdir_in(&parent) {
Ok(probes) => {
for (name, probe_name) in names {
let probe = probes.path().join(probe_name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
Ok(file) => drop(file),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(failed(format!("two files use the same output path: {name}")));
}
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
}
}
}
#[cfg(windows)]
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => probe_files_for_resolved_outputs(&parent, names)?,
Err(error) => return Err(failed(error)),
}
}
Ok(())
}
#[cfg(windows)]
fn probe_files_for_resolved_outputs(parent: &Path, names: Vec<(String, std::ffi::OsString)>) -> Result<()> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
let mut probes = Vec::with_capacity(names.len());
for (name, probe_name) in names {
let probe = parent.join(probe_name);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true).share_mode(0).custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
match options.open(&probe) {
Ok(file) => probes.push(file),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(failed(format!("two files use the same output path: {name}")));
}
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
}
}
drop(probes);
Ok(())
}
fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
fn key(name: &std::ffi::OsStr) -> Vec<u32> {
std::char::decode_utf16(name.encode_wide())
.map(|decoded| match decoded {
Ok(character) => {
let mut uppercase = character.to_uppercase();
match (uppercase.next(), uppercase.next()) {
(Some(single), None) => single as u32,
_ => character as u32,
}
}
Err(error) => 0x11_0000 + u32::from(error.unpaired_surrogate()),
})
.collect()
}
key(left) == key(right)
}
#[cfg(not(windows))]
{
left == right
}
}
fn output_probe_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
let name = path.file_name().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "output has no file name"))?;
#[cfg(windows)]
{
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let is_verbatim = path.components().any(|component| matches!(component, std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) | std::path::Prefix::VerbatimDisk(_))));
let mut wide: Vec<u16> = name.encode_wide().collect();
if !is_verbatim {
while wide.last().is_some_and(|unit| *unit == u16::from(b'.') || *unit == u16::from(b' ')) {
wide.pop();
}
}
Ok(std::ffi::OsString::from_wide(&wide))
}
#[cfg(not(windows))]
{
Ok(name.to_os_string())
}
}
/// Publish a PDF atomically while refusing a target created by another process after preflight.
pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
@@ -212,3 +329,104 @@ pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
}
})
}
#[cfg(test)]
mod tests {
use super::check_unique_outputs;
#[test]
fn dry_run_path_check_creates_no_probe_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("same.pdf");
let targets = vec![("first.pdf".into(), path.clone()), ("second.pdf".into(), path)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(unix)]
#[test]
fn dry_run_detects_output_parent_aliases_without_writing() {
let dir = tempfile::tempdir().unwrap();
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(dir.path(), &alias).unwrap();
let first = dir.path().join("same.pdf");
let second = alias.join("same.pdf");
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn long_output_names_fit_in_probe_directory() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join(format!("{}.pdf", "a".repeat(220)));
let second = dir.path().join(format!("{}.pdf", "b".repeat(220)));
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(unix)]
#[test]
fn output_probe_paths_preserve_non_utf8_names() {
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xff]));
let second = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xfe]));
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn windows_probe_names_trim_only_non_verbatim_paths() {
use std::ffi::OsString;
use std::path::Path;
let normal = Path::new(r"C:\root\report.pdf. ");
assert_eq!(super::output_probe_name(normal).unwrap(), OsString::from("report.pdf"));
let verbatim = Path::new(r"\\?\C:\root\report.pdf. ");
assert_eq!(super::output_probe_name(verbatim).unwrap(), OsString::from("report.pdf. "));
}
#[cfg(windows)]
#[test]
fn dry_run_detects_case_only_output_collisions_without_writing() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("Case.pdf");
let second = dir.path().join("CASE.pdf");
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn permission_denied_child_directory_uses_delete_on_close_file_probes() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let identity = Command::new("whoami.exe").args(["/user", "/fo", "csv", "/nh"]).output().unwrap();
assert!(identity.status.success());
let identity = String::from_utf8(identity.stdout).unwrap();
let sid = identity.split('"').nth(3).unwrap();
let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap();
assert!(acl.success());
// Where the deny entry does not bind the test's own account (it did not on CI's Windows image),
// there is no denied folder to probe.
if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) {
return;
}
let direct_probe = dir.path().join("direct-probe");
let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap();
drop(file);
std::fs::remove_file(&direct_probe).unwrap();
let targets = vec![("first.pdf".into(), dir.path().join("first.pdf")), ("second.pdf".into(), dir.path().join("second.pdf"))];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
}
+5 -1
View File
@@ -490,8 +490,12 @@ impl Automation {
if contents.trim().is_empty() {
return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into()));
}
// `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the
// caret carries the colour too. Only the colour: opacity and the rest are the
// caret's own.
let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
caret.color = style.color;
let strike = style;
let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?;
out["comment"] = json!({ "page": page + 1, "type": "replace" });
return Ok(out);
+64 -11
View File
@@ -354,14 +354,32 @@ impl Automation {
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
/// The references `objects` names (from object_list), or with `all` every object on `page`,
/// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's.
fn object_targets(&self, a: &Args, page: usize) -> Result<Vec<pdfcraft_engine::ObjectTarget>> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again"));
}
}
if a.opt_bool("all")?.unwrap_or(false) {
if a.get("objects").is_some() {
return Err(bad("give objects or all, not both"));
}
let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect();
if all.is_empty() || all.len() > MAX_MOVE_OBJECTS {
return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len())));
}
return Ok(all);
}
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
@@ -382,6 +400,12 @@ impl Automation {
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
Ok(targets)
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let delta = a
.get("offset")
.and_then(Value::as_array)
@@ -389,17 +413,46 @@ impl Automation {
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
/// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into
/// any open document. A cut then deletes them as one undo step.
pub(crate) fn object_copy(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let cut = a.opt_bool("cut")?.unwrap_or(false);
let doc = self.doc(a)?;
if cut && !doc.allows_modification() {
return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes")));
}
let clip = doc.copy_objects(page, &targets).map_err(failed)?;
let out = json!({ "copied": clip.objects().len(), "text": clip.text() });
self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
if !cut {
return Ok(out);
}
let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?;
let mut result = self.apply(a, delete)?;
result["copied"] = out["copied"].clone();
result["text"] = out["text"].clone();
Ok(result)
}
/// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the
/// page they came from (and on each further paste onto a page) 18 points down and right.
pub(crate) fn object_paste(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?;
let count = clipboard.clip().objects().len();
let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed));
self.objects_clipboard = Some(clipboard);
let mut result = self.apply(a, edit?)?;
result["pasted"] = json!(count);
Ok(result)
}
}
+7
View File
@@ -467,6 +467,7 @@ impl Automation {
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
"remove_crop" => ImageEdit::RemoveCrop,
"ungroup" => ImageEdit::Ungroup,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -480,6 +481,10 @@ impl Automation {
self.apply(&a, Edit::EditPageImage { page, index, change })?
}
}
"text_prepare" => {
let page = self.page(&a)?;
self.prepare_text(&a, page)?
}
"text_paragraphs" => {
let page = self.page(&a)?;
let doc = self.doc_to_copy(&a)?;
@@ -733,6 +738,8 @@ impl Automation {
"links_remove" => self.links_remove(&a)?,
"object_list" => self.object_list(&a)?,
"object_move" => self.object_move(&a)?,
"object_copy" => self.object_copy(&a)?,
"object_paste" => self.object_paste(&a)?,
"content_list" => self.content_list(&a)?,
"page_add_text" => self.page_add_text(&a)?,
"page_add_image" => self.page_add_image(&a)?,
+85 -10
View File
@@ -1,11 +1,15 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, nest_rows, security, summary};
use super::paths::{child, read_input, write_atomic};
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, TextBlock, Value, comments, json};
/// 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;
/// `doc_optimize` with `target_bytes`: (max target ppi, max JPEG quality) per extra attempt, mildest first.
/// The quality applies only to images the caller left on JPEG; zip and retain keep their encoding.
const TARGET_LADDER: [(f64, u8); 5] = [(110.0, 45), (96.0, 35), (72.0, 28), (54.0, 20), (36.0, 12)];
/// Bookmarks in one page of the bookmark list, by default and at most.
const BOOKMARK_PAGE: usize = 100;
@@ -131,18 +135,18 @@ impl Automation {
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")? {
let explicit = a.opt_str("path")?;
let target = match explicit {
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.
// allows and the write root may not. It also goes through --root again: a
// folder on the remembered path may have become a link out since (#1791).
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
self.resolve(&own, true)?
}
};
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
let same_file = explicit.is_none() || 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);
@@ -380,7 +384,45 @@ impl Automation {
})
.collect::<Result<_>>()?,
};
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
let target = match a.opt_int("target_bytes")? {
None => None,
Some(t) if t >= 1 => Some(usize::try_from(t).map_err(|_| ToolError::InvalidArgs(format!("target_bytes {t} is too large")))?),
Some(t) => return Err(ToolError::InvalidArgs(format!("target_bytes must be at least 1, not {t}"))),
};
let (mut bytes, mut r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
let mut attempts = 1;
let mut step_used = 0;
// Settings already optimized with; a rung that changes nothing is not run again.
let mut tried = vec![settings.clone()];
if let Some(t) = target {
// Stronger image settings, one rung per attempt, until the copy fits. Every attempt
// starts from the open document, so a failed one changes nothing; the number of
// attempts is fixed. The smallest copy is kept if none fits.
for (k, &(ppi, quality)) in TARGET_LADDER.iter().enumerate() {
if bytes.len() <= t {
break;
}
let mut s = settings.clone();
for img in [&mut s.color, &mut s.gray] {
img.downsample = true;
img.target_ppi = img.target_ppi.min(ppi);
img.above_ppi = img.above_ppi.min(ppi * 1.5).max(img.target_ppi);
// Only a caller who chose JPEG gets a lower quality; zip and retain stay as asked.
if let Compression::Jpeg(q) = img.compression {
img.compression = Compression::Jpeg(q.min(quality));
}
}
if tried.contains(&s) {
continue;
}
let (b2, r2) = self.session.optimized_bytes(id, &s, &discard).map_err(failed)?;
tried.push(s);
attempts += 1;
if b2.len() < bytes.len() {
(bytes, r, step_used) = (b2, r2, k + 1);
}
}
}
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 }
@@ -400,7 +442,19 @@ impl Automation {
// 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!({
let mut extra = json!({});
if let Some(t) = target {
let (ppi, quality) = step_used.checked_sub(1).and_then(|k| TARGET_LADDER.get(k)).copied().unzip();
let lossy = matches!(settings.color.compression, Compression::Jpeg(_)) || matches!(settings.gray.compression, Compression::Jpeg(_));
let quality = quality.filter(|_| lossy);
extra = json!({
"target_bytes": t,
"target_reached": bytes.len() <= t,
"attempts": attempts,
"target_settings": { "step": step_used, "max_ppi": ppi, "max_jpeg_quality": quality },
});
}
let mut out = json!({
"path": path.to_string_lossy(),
"pdf_version": pdf_version,
"object_streams": settings.effective_object_streams(),
@@ -425,7 +479,11 @@ impl Automation {
"unused_xobjects": o.unused_xobjects,
"merged_objects": r.merged,
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
}))
});
if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
o.extend(e.iter().map(|(k, v)| (k.clone(), v.clone())));
}
Ok(out)
}
/// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based).
@@ -444,6 +502,23 @@ impl Automation {
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)?;
self.applied(a)
}
/// `text_prepare`: make a page's grouped text editable, with what that edit itself prepared,
/// repaired and left grouped.
pub(crate) fn prepare_text(&mut self, a: &Args, page: usize) -> Result<Value> {
let id = self.doc(a)?.id;
let prepared = self.session.prepare_text(id, page).map_err(failed)?;
let mut out = self.applied(a)?;
out["prepared_groups"] = json!(prepared.expanded);
out["repaired_fonts"] = json!(prepared.repaired_fonts);
out["refused_groups"] = json!(prepared.refused);
Ok(out)
}
/// The document's summary after an edit, with what it stored as typed but drew with `?`.
fn applied(&mut self, a: &Args) -> Result<Value> {
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.
+127 -43
View File
@@ -5,10 +5,13 @@
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The path is made absolute against the working directory, then followed the way the file
//! system reads it, one name at a time from the top, its links followed. A name is looked up
//! only while the folder it is in is the root, inside it, or one of the root's own parent
//! folders: a path that leaves those is refused before anything beyond is touched (looking up
//! `/net/<host>` mounts it), and every such refusal reads the same, so it can't tell what exists
//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken
//! as 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.
@@ -20,7 +23,6 @@
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
//! files it was given; they don't guard against other programs on the computer.
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
@@ -100,11 +102,10 @@ impl AutomationRoots {
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
};
};
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(());
match follow(shown, &absolute(shown, target)?, folder) {
Toward::Inside => return Ok(()),
Toward::Refused(why) => return Err(why),
Toward::Outside => {}
}
let root = root_shown.display();
match access {
@@ -141,42 +142,66 @@ fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
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;
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
/// Where a followed path ends up.
pub(crate) enum Toward {
/// Inside the root, with its links followed (a missing tail taken as names).
Inside,
/// Anywhere else. Said the same way whatever is out there.
Outside,
/// Inside the root, but it can't be followed, and why.
Refused(String),
}
/// Follow the absolute `path` the way the file system reads it, one name at a time from the top,
/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own
/// parent folders (all with their links followed already, so `..` means what it means to the file
/// system).
pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward {
// Another network share or a device is refused by name, as `--root` refuses it: even looking
// it up would contact the host (#4003).
if foreign_share(path, root) {
return Toward::Outside;
}
let near = |at: &Path| inside(at, root) || inside(root, at);
let mut parts = path.components().peekable();
let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect();
let mut at = match std::fs::canonicalize(&top) {
Ok(at) if near(&at) => at,
_ => return Toward::Outside,
};
while let Some(part) = parts.next() {
let name = match part {
Component::Normal(name) => name,
Component::CurDir => continue,
Component::ParentDir => {
at.pop();
continue;
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
Component::Prefix(_) | Component::RootDir => return Toward::Outside,
};
let next = at.join(name);
match std::fs::canonicalize(&next) {
Ok(found) if near(&found) => at = found,
Ok(_) => return Toward::Outside,
// Not even there yet: only inside the root does it matter why.
Err(_) if !inside(&at, root) => return Toward::Outside,
Err(e) => {
return match std::fs::symlink_metadata(&next) {
Ok(m) if m.file_type().is_symlink() => Toward::Refused(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) {
return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
Toward::Inside
}
Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
};
}
}
}
if inside(&at, root) { Toward::Inside } else { Toward::Outside }
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
@@ -306,6 +331,62 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// Outside the root, the refusal comes before anything out there is looked up, so it can't
/// tell whether a folder out there exists.
#[test]
fn paths_out_of_the_root_are_refused_alike_without_looking() {
let (base, roots) = folders("follow");
let sep = std::path::MAIN_SEPARATOR;
let root = s(base.join("in"));
let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, "<path>");
// Through a folder outside that exists, and through one that doesn't.
let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf");
let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf");
assert_eq!(refusal(&there), refusal(&missing));
assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing));
// Out through a missing folder and back in. (Windows folds `..` by name when it makes a path
// absolute, as it does when it opens one: that path is `in\a.pdf` there.)
#[cfg(not(windows))]
assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root"));
// A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it.
#[cfg(windows)]
{
let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}");
assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf")));
assert!(
refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"),
"{}",
refusal(&verbatim(r"..\nowhere\x.pdf"))
);
}
let _ = std::fs::remove_dir_all(base);
}
/// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on
/// macOS, where the temporary folder is reached through a link).
#[test]
fn a_root_may_be_named_as_given_or_as_resolved() {
let (base, roots) = folders("given");
let resolved = std::fs::canonicalize(base.join("in")).unwrap();
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// A link beside the root, in one of its parent folders, is followed: to the root it leads in,
/// anywhere else it leads out.
#[cfg(unix)]
#[test]
fn links_beside_the_root_are_followed() {
let (base, roots) = folders("beside");
std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap();
assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(()));
assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
@@ -393,7 +474,8 @@ mod tests {
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
// inputs and out_dir, walks only inside them (skipping links) and resolves every
// discovered file again before `process` reads it or `publish` writes it.
// discovered file again before `process` reads it or `publish` writes it. `doc_bates_multi`
// resolves its paths before checking output collisions and using the probe helpers.
const RESOLVED_BY_CALLER: &[&str] = &[
"write_atomic",
"write_atomic_with",
@@ -405,6 +487,8 @@ mod tests {
"linked",
"walk",
"publish",
"check_unique_outputs",
"probe_files_for_resolved_outputs",
];
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut files = vec![];
+51 -30
View File
@@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value {
})
}
/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks,
/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs.
pub(crate) fn info(d: &Document) -> Value {
let i = &d.info;
let page1 = |p: usize| p + 1;
@@ -294,7 +296,7 @@ pub(crate) fn info(d: &Document) -> Value {
// 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,
"creator": i.creator, "producer": i.producer, "created": i.created, "modified": i.modified,
"tagged": i.tagged,
"has_javascript": i.has_javascript,
// Where the document opens (/OpenAction, named destinations resolved): 1-based page and
@@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value {
"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": outline(i, &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
@@ -329,32 +331,7 @@ pub(crate) fn info(d: &Document) -> Value {
})).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 }),
},
"target": link_target(i, &l.target),
})).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
@@ -389,6 +366,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [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())
/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`).
fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(
items
.iter()
.map(|o| {
json!({
"title": o.title, "page": o.page.map(|p| p + 1),
"action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children),
})
})
.collect(),
)
}
/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer
/// changes, another PDF, or the name of an action that isn't run.
fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value {
// A remote page number is the file's, however large.
let page1 = |p: usize| p.saturating_add(1);
match 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 }),
}
}
+4 -2
View File
@@ -48,7 +48,6 @@ use serde_json::{Value, json};
pub use pdfcraft_render::Pixels;
pub use tools::{ToolDef, tools};
use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
@@ -71,7 +70,7 @@ use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content};
use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel};
#[cfg(test)]
pub(crate) use paths::MAX_INPUT;
use paths::{child, lexical, unused, write_atomic};
use paths::{child, lexical, write_atomic};
pub(crate) use paths::{read_input, read_input_bytes};
#[cfg(test)]
use paths::{write_atomic_synced, write_atomic_with};
@@ -101,6 +100,8 @@ pub struct Automation {
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
ocr_reviews: HashMap<DocId, OcrReview>,
next_ocr_review: u64,
/// What object_copy copied, for object_paste into any open document.
objects_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
}
impl Default for Automation {
@@ -121,6 +122,7 @@ impl Automation {
evicted: HashSet::new(),
ocr_reviews: HashMap::new(),
next_ocr_review: 0,
objects_clipboard: None,
}
}
+65 -13
View File
@@ -2,7 +2,7 @@
//! all links. Rectangles are points from the top-left of the displayed page.
use pdfcraft_engine::{
Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
Edit, LinkAction, LinkHighlight, LinkStyle, LinkZoom, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
use serde_json::{Value, json};
@@ -25,18 +25,30 @@ impl Automation {
Ok(Some([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]))
}
fn link_action(&self, a: &Args) -> Result<Option<LinkAction>> {
/// The action `url`, `to_page` and `zoom` ask for, if any. `current` is the link's action now
/// (link_edit): a new page keeps the link's zoom, and a zoom alone keeps its page.
fn link_action(&self, a: &Args, current: Option<&LinkAction>) -> Result<Option<LinkAction>> {
let zoom = link_zoom(a)?;
let (current_page, current_zoom) = match current {
Some(LinkAction::Page { page, zoom }) => (Some(*page), Some(*zoom)),
_ => (None, None),
};
match (a.opt_str("url")?, a.opt_int("to_page")?) {
(Some(_), Some(_)) => Err(bad("pass url or to_page, not both")),
(Some(_), None) if zoom.is_some() => Err(bad("zoom goes with to_page, not url")),
(Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))),
(None, Some(p)) => {
let n = self.doc(a)?.info.pages.len() as i64;
if p < 1 || p > n {
return Err(bad(format!("to_page must be 1–{n}")));
}
Ok(Some(LinkAction::Page(p as usize - 1)))
Ok(Some(LinkAction::Page { page: p as usize - 1, zoom: zoom.or(current_zoom).unwrap_or(LinkZoom::FitPage) }))
}
(None, None) => Ok(None),
(None, None) => match (zoom, current_page) {
(None, _) => Ok(None),
(Some(zoom), Some(page)) => Ok(Some(LinkAction::Page { page, zoom })),
(Some(_), None) => Err(bad("zoom needs to_page: the link doesn't go to a page")),
},
}
}
@@ -122,11 +134,16 @@ impl Automation {
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
v["destination"] = d;
}
if matches!(action, LinkAction::Other(_)) {
v["named"] = json!(true);
match action {
LinkAction::Page { zoom, .. } => v["zoom"] = zoom_value(*zoom),
LinkAction::Other(_) => v["named"] = json!(true),
LinkAction::Uri(_) => {}
}
}
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
(LinkAction::Page { page, zoom }, None) => {
v["to_page"] = json!(page + 1);
v["zoom"] = zoom_value(*zoom);
}
(LinkAction::Other(o), None) => v["other"] = json!(o),
}
v
@@ -204,12 +221,13 @@ impl Automation {
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?;
let action = self.link_action(a, None)?.ok_or_else(|| bad("pass url or to_page"))?;
let style = self.link_style(a, LinkStyle::default())?;
self.apply(a, Edit::AddLink { page, rect, action, style })
}
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> {
/// The link `page` and `index` name: its page, index in `/Annots`, appearance and action.
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle, LinkAction)> {
let page = self.page(a)?;
let index = a.int("index")?;
let doc = self.doc(a)?;
@@ -218,19 +236,19 @@ impl Automation {
.iter()
.find(|l| l.page == page && l.index as i64 + 1 == index)
.ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?;
Ok((page, l.index, l.style))
Ok((page, l.index, l.style, l.action.clone()))
}
pub(crate) fn link_edit(&mut self, a: &Args) -> Result<Value> {
let (page, index, style) = self.link_index(a)?;
let (page, index, style, current) = self.link_index(a)?;
let rect = self.view_rect(a, page)?;
let action = self.link_action(a)?;
let action = self.link_action(a, Some(&current))?;
let new_style = self.link_style(a, style)?;
self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) })
}
pub(crate) fn link_delete(&mut self, a: &Args) -> Result<Value> {
let (page, index, _) = self.link_index(a)?;
let (page, index, ..) = self.link_index(a)?;
self.apply(a, Edit::DeleteLink { page, index })
}
@@ -261,6 +279,40 @@ impl Automation {
}
}
/// The zoom a `zoom` argument names: fit_page, fit_width, fit_height, fit_visible, actual
/// (100%), inherit (keep the reader's zoom), or a percentage, kept within 1–6400.
fn link_zoom(a: &Args) -> Result<Option<LinkZoom>> {
match a.get("zoom") {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(p)) => match p.as_f64() {
Some(p) if p.is_finite() && p > 0.0 => Ok(Some(LinkZoom::Percent(p.clamp(1.0, 6400.0)))),
_ => Err(bad("zoom must be a positive percentage")),
},
Some(Value::String(z)) => Ok(Some(match z.as_str() {
"fit_page" => LinkZoom::FitPage,
"fit_width" => LinkZoom::FitWidth,
"fit_height" => LinkZoom::FitHeight,
"fit_visible" => LinkZoom::FitVisible,
"actual" => LinkZoom::Percent(100.0),
"inherit" => LinkZoom::Inherit,
o => return Err(bad(format!("unknown zoom {o:?}"))),
})),
Some(_) => Err(bad("zoom is a name or a percentage")),
}
}
/// How `link_list` reports a link's zoom: the names `link_zoom` reads, a percentage as a number.
fn zoom_value(zoom: LinkZoom) -> Value {
match zoom {
LinkZoom::FitPage => json!("fit_page"),
LinkZoom::FitWidth => json!("fit_width"),
LinkZoom::FitHeight => json!("fit_height"),
LinkZoom::FitVisible => json!("fit_visible"),
LinkZoom::Inherit => json!("inherit"),
LinkZoom::Percent(p) => json!(p),
}
}
fn dest_view_value(view: DestView) -> Value {
match view {
DestView::Top => json!({ "kind": "top" }),
+70 -3
View File
@@ -2,7 +2,9 @@
use pdfcraft_engine::model3d::Model3dKind;
use pdfcraft_prc::PrcFile;
use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render};
use pdfcraft_prc_render::nav::{Axis, NamedView, Orbit, Upright, reach};
use pdfcraft_prc_render::{Bounds, Camera, RenderOptions, WorldMesh, assemble, render};
use serde_json::Value;
use super::Automation;
use super::dispatch::Content;
@@ -22,7 +24,6 @@ impl Automation {
let page = self.page(a)?;
let width = side(a, "width")?;
let height = side(a, "height")?;
let direction = direction(a)?;
let perspective = a.opt_bool("perspective")?.unwrap_or(true);
let doc = self.doc(a)?;
let found = doc.model_3d_annotations(page).map_err(failed)?;
@@ -42,7 +43,14 @@ impl Automation {
let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
let meshes = assemble(&prc).map_err(failed)?;
let aspect = f64::from(width) / f64::from(height);
let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?;
let camera = match a.get("orbit") {
Some(_) if a.get("direction").is_some() => return Err(Args::wrong("orbit", "left out when direction is given")),
Some(moves) => orbit_camera(moves, &meshes, perspective, aspect)?,
None => {
let direction = direction(a)?;
Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?
}
};
let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
Ok(vec![Content::Png { data, width: image.width, height: image.height }])
@@ -71,6 +79,65 @@ fn direction(a: &Args) -> Result<[f64; 3]> {
Ok(out)
}
/// The keys `orbit` takes.
const ORBIT_KEYS: [&str; 7] = ["view", "up", "front", "turn", "zoom", "zoom_at", "pan"];
/// The camera `orbit` names: a named view about an up axis, then a turn, a zoom and a pan, as a
/// viewer's orbit control would apply them.
fn orbit_camera(moves: &Value, meshes: &[WorldMesh], perspective: bool, aspect: f64) -> Result<Camera> {
let wrong = |what: &str| Args::wrong("orbit", what);
let moves = moves.as_object().ok_or_else(|| wrong("an object"))?;
if let Some(key) = moves.keys().find(|k| !ORBIT_KEYS.contains(&k.as_str())) {
return Err(wrong(&format!("an object without {key}; its keys are {}", ORBIT_KEYS.join(", "))));
}
let token = |key: &str| moves.get(key).map(|v| v.as_str().ok_or_else(|| wrong(&format!("{key} as a string"))));
let axis = |key: &str| token(key).map(|t| Axis::from_token(t?).ok_or_else(|| wrong(&format!("{key} as one of +x, +y, +z, -x, -y, -z"))));
let mut upright = Upright::default();
if let Some(up) = axis("up") {
upright = upright.with_up(up?);
}
if let Some(front) = axis("front") {
let front = front?;
if !upright.up().perpendicular(front) {
return Err(wrong("front perpendicular to up"));
}
upright = upright.with_front(front);
}
let view = match token("view") {
None => NamedView::Isometric,
Some(t) => NamedView::from_token(t?).ok_or_else(|| wrong("view as one of iso, front, right, top, back"))?,
};
let mut orbit = Orbit::named(view, perspective, upright);
let bounds = Bounds::of(meshes).ok_or_else(|| failed("the model has no vertex"))?;
let reach = reach(meshes, &bounds);
if let Some(v) = moves.get("turn") {
let [yaw, pitch] = pair(v).ok_or_else(|| wrong("turn as [yaw, pitch] in degrees"))?;
orbit.turn(yaw.to_radians(), pitch.to_radians());
}
if let Some(v) = moves.get("zoom") {
let factor = v.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| wrong("zoom as a positive number"))?;
let at = match moves.get("zoom_at") {
None => [0.0, 0.0],
Some(v) => pair(v).ok_or_else(|| wrong("zoom_at as [right, up] in picture heights"))?,
};
orbit.zoom_at(&bounds, reach, aspect, factor, at);
} else if moves.contains_key("zoom_at") {
return Err(wrong("an object with zoom when it has zoom_at"));
}
if let Some(v) = moves.get("pan") {
orbit.pan_by(pair(v).ok_or_else(|| wrong("pan as [right, up] in model radii"))?);
}
orbit.camera(&bounds, reach, aspect).map_err(failed)
}
/// Two finite numbers.
fn pair(v: &Value) -> Option<[f64; 2]> {
match v.as_array()?.as_slice() {
[a, b] => Some([a.as_f64().filter(|f| f.is_finite())?, b.as_f64().filter(|f| f.is_finite())?]),
_ => None,
}
}
/// Z is up, as CAD models are drawn, unless the view looks along it.
fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
+22 -4
View File
@@ -270,6 +270,8 @@ impl Automation {
/// `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.
/// `stop_at_limit` drops that lookahead: the search ends at the `limit`th match, so a lone match
/// never sends it to the end of the document, and `next` is only where to resume (#5328).
pub(crate) fn text_find(&mut self, a: &Args) -> Result<Value> {
self.text_find_with(a, &mut extract_parallel)
}
@@ -289,11 +291,15 @@ impl Automation {
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 stop_at_limit = a.opt_bool("stop_at_limit")?.unwrap_or(false);
let mut matches = Vec::new();
let mut next = None;
let mut stopped = false;
let mut warnings = Vec::new();
let mut warning_count = 0usize;
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
// A search that stops at its limit starts with one page, so a match on it reads nothing
// more; the batches still double for a match further in.
let (mut first, mut batch) = (start, if stop_at_limit { 1 } else { FIND_FIRST_BATCH });
'search: while first < n {
let end = first.saturating_add(batch).min(n);
let pages: Vec<usize> = (first..end).collect();
@@ -315,13 +321,19 @@ impl Automation {
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);
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(usize::from(!stop_at_limit));
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) }));
if stop_at_limit && matches.len() == limit {
// Resume just past this match; whether another exists isn't looked at.
next = Some(json!({ "start_page": p + 1, "skip": k.saturating_add(1) }));
stopped = true;
break 'search;
}
}
}
first = end;
@@ -329,8 +341,13 @@ impl Automation {
}
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);
if let Some(o) = out.as_object_mut() {
if let Some(next) = next {
o.insert("next".into(), next);
}
if stop_at_limit {
o.insert("stopped_at_limit".into(), stopped.into());
}
}
Ok(out)
}
@@ -393,6 +410,7 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str
match warning {
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give",
}
}
+8 -2
View File
@@ -1,5 +1,5 @@
use super::error::{Result, failed};
use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
use super::{Automation, Path, PathBuf, StagingName, file_access, staging_suffixes};
impl Automation {
// ---- paths -------------------------------------------------------------------------------
@@ -39,7 +39,13 @@ impl Automation {
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) {
// Followed one name at a time first: nothing outside the root or its own parent folders is
// looked up, not even to see whether it exists. What can't be followed inside the root is
// said below. Made absolute the way Windows reads it first: it drops a folder name's
// trailing dot (`in.` is `in`), but `follow` looks names up below `\\?\` folders, where it
// doesn't.
let as_read = std::path::absolute(&joined).map_err(|_| outside())?;
if matches!(file_access::follow(path, &as_read, root), file_access::Toward::Outside) {
return Err(outside());
}
let mut existing = joined.as_path();
+18 -2
View File
@@ -134,6 +134,18 @@ impl Automation {
"shrink" => Layout::Size(SizeMode::Shrink),
"custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)),
"multiple" => {
// Custom pages per sheet: `cols` across by `rows` down, both or neither.
let grid = match (a.opt_int("cols")?, a.opt_int("rows")?) {
(None, None) => None,
(Some(cols), Some(rows)) => {
if a.opt_int("per_sheet")?.is_some() {
return Err(bad("give per_sheet, or cols and rows, not both"));
}
let grid = usize::try_from(cols).ok().zip(usize::try_from(rows).ok()).and_then(|(cols, rows)| Layout::grid(cols, rows));
Some(grid.ok_or_else(|| bad(format!("cols and rows must each be 1 to {}", print::MAX_GRID)))?)
}
_ => return Err(bad("a custom grid needs both cols and rows")),
};
let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize;
let order = match a.opt_str("order")?.unwrap_or("horizontal") {
"horizontal" => PageOrder::Horizontal,
@@ -143,14 +155,15 @@ impl Automation {
"cut-stack" => PageOrder::CutStack,
o => return Err(bad(format!("unknown order {o:?}"))),
};
match Layout::multiple(per) {
Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple {
match grid.unwrap_or_else(|| Layout::multiple(per)) {
Layout::Multiple { cols, rows, per_sheet, custom, .. } => Layout::Multiple {
cols,
rows,
per_sheet,
order,
border: a.opt_bool("border")?.unwrap_or(false),
auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true),
custom,
},
l => l,
}
@@ -172,6 +185,9 @@ impl Automation {
l => return Err(bad(format!("unknown layout {l:?}"))),
};
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cols and rows need layout multiple"));
}
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cut-stack order needs layout multiple"));
}
+41 -6
View File
@@ -2,8 +2,8 @@
//! (removing what they cover for good) or clear them.
use pdfcraft_engine::{
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad,
redact_word_list,
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
};
use serde_json::{Value, json};
@@ -59,16 +59,20 @@ impl Automation {
a.get("find").is_some(),
a.get("words").is_some(),
a.get("pattern").is_some(),
a.get("regex").is_some(),
a.opt_bool("whole_pages")?.unwrap_or(false),
];
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern, regex or whole_pages: true".into()));
}
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
}
if a.get("validate").is_some() && a.get("regex").is_none() {
return Err(ToolError::InvalidArgs("validate applies only to regex".into()));
}
// Per searched word or phrase: how many marks it made.
let mut matched_words: Vec<(String, usize)> = Vec::new();
if a.get("rect").is_some() {
@@ -89,6 +93,19 @@ impl Automation {
Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?),
None => None,
};
let user_re = match a.opt_str("regex")? {
Some(src) => {
let validator = match a.opt_str("validate")? {
Some(v) => Some(RedactValidator::from_id(v).ok_or_else(|| {
let ids: Vec<_> = REDACT_VALIDATORS.iter().map(|v| v.id()).collect();
ToolError::InvalidArgs(format!("unknown validate {v:?}; allowed: {}", ids.join(", ")))
})?),
None => None,
};
Some(RedactRegex::new(src, validator).map_err(|e| ToolError::InvalidArgs(e.to_string()))?)
}
None => None,
};
let needles: Vec<String> = match (a.opt_str("find")?, a.get("words")) {
(Some(f), _) => vec![f.to_owned()],
(None, Some(_)) => {
@@ -108,9 +125,27 @@ impl Automation {
let texts = self.page_texts(id, &pages)?;
let info = self.doc(a)?.info.clone();
for (&p, text) in pages.iter().zip(&texts) {
let hits: Vec<(Option<usize>, _)> = match pattern {
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
None => needles
let hits: Vec<(Option<usize>, _)> = match (&user_re, pattern) {
(Some(re), _) => {
// A page with too many matches fails the call: marking only the first ones
// would leave the rest readable after redact_apply.
let too_many = std::cell::Cell::new(false);
let found = text.find_with(|chars| {
re.find(chars).unwrap_or_else(|_| {
too_many.set(true);
Vec::new()
})
});
if too_many.get() {
return Err(ToolError::InvalidArgs(format!(
"regex has more than 10000 matches on page {}; nothing was marked. Narrow the expression so that every match can be marked",
p + 1
)));
}
found.into_iter().map(|h| (None, h)).collect()
}
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
(None, None) => needles
.iter()
.enumerate()
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
+1
View File
@@ -119,6 +119,7 @@ impl Automation {
"page": s.page.map(|p| p + 1),
"rect": rect,
"visible": s.visible,
"verdict_shown": s.verdict_shown,
"revision": s.revision,
"sub_filter": s.sub_filter,
"algorithm": s.algorithm,
+125
View File
@@ -146,6 +146,84 @@ fn search_bounds_warning_volume_and_does_not_reach_distant_failures() {
assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true));
}
#[test]
fn stop_at_limit_ends_at_a_lone_match_without_reading_the_distant_tail() {
let mut a = Automation::new();
let id = search_document(&mut a, 200);
// #5328: "Page 1" is only on page 1 (every other readable page reads "Page 2"), and the last
// page fails, so only a search that runs on to the end hears about it.
let requested = std::cell::RefCell::new(Vec::new());
let mut extract = |bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]| {
requested.borrow_mut().extend_from_slice(pages);
let filler = extract_parallel(bytes, password.clone(), &[1]).remove(0).1;
let first = extract_parallel(bytes, password, &[0]).remove(0).1;
pages
.iter()
.map(|&p| {
(
p,
match p {
0 => first.clone(),
199 => Err("synthetic page 200 failure".into()),
_ => filler.clone(),
},
)
})
.collect::<Vec<_>>()
};
let looked_ahead = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page 1", "limit": 1 })), &mut extract).unwrap();
assert_eq!((looked_ahead["count"].clone(), looked_ahead["warning_count"].clone()), (json!(1), json!(1)), "{looked_ahead}");
assert!(looked_ahead.get("next").is_none() && looked_ahead.get("stopped_at_limit").is_none());
a.texts.remove(&id);
requested.borrow_mut().clear();
let args = json!({ "doc": id.0, "query": "Page 1", "limit": 1, "stop_at_limit": true });
let stopped = a.text_find_with(&Args(&args), &mut extract).unwrap();
assert_eq!(stopped["matches"][0]["page"], 1);
assert_eq!((stopped["count"].clone(), stopped["warning_count"].clone()), (json!(1), json!(0)), "{stopped}");
assert_eq!((stopped["complete"].clone(), stopped["stopped_at_limit"].clone()), (json!(false), json!(true)));
assert_eq!(stopped["next"], json!({ "start_page": 1, "skip": 1 }));
assert_eq!(*requested.borrow(), [0], "a match on the first page reads only that page");
// Resuming finds nothing more and reaches the failing page, which it now reports.
let mut resume = args.clone();
resume["start_page"] = stopped["next"]["start_page"].clone();
resume["skip"] = stopped["next"]["skip"].clone();
let rest = a.text_find_with(&Args(&resume), &mut extract).unwrap();
assert_eq!((rest["count"].clone(), rest["stopped_at_limit"].clone(), rest["complete"].clone()), (json!(0), json!(false), json!(false)));
assert_eq!(rest["warnings"][0]["page"], 200);
assert!(rest.get("next").is_none());
}
#[test]
fn stop_at_limit_continuations_return_every_match_once_and_end_complete() {
let mut a = Automation::new();
let id = search_document(&mut a, 120);
let all = json(&mut a, "text_find", json!({ "doc": id.0, "query": "1", "limit": 10_000 }));
assert_eq!(all["complete"], true);
// "1" appears twice on pages like 11 and 111, so resuming has to skip within a page.
for limit in [1, 2, 3] {
let mut args = json!({ "doc": id.0, "query": "1", "limit": limit, "stop_at_limit": true });
let mut pieced = Vec::new();
for _ in 0..=all["count"].as_u64().unwrap() {
let part = json(&mut a, "text_find", args.clone());
assert_eq!(part["warning_count"], 0);
pieced.extend(part["matches"].as_array().unwrap().iter().cloned());
if part["stopped_at_limit"] == false {
assert_eq!(part["complete"], true);
assert!(part.get("next").is_none());
break;
}
assert_eq!((part["count"].clone(), part["complete"].clone()), (json!(limit), json!(false)));
args["start_page"] = part["next"]["start_page"].clone();
args["skip"] = part["next"]["skip"].clone();
}
assert_eq!(pieced, *all["matches"].as_array().unwrap(), "limit {limit}: no match lost or repeated");
}
let none = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent", "limit": 1, "stop_at_limit": true }));
assert_eq!((none["count"].clone(), none["complete"].clone(), none["stopped_at_limit"].clone()), (json!(0), json!(true), json!(false)));
}
#[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()));
@@ -192,6 +270,30 @@ fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_save_without_a_path_stays_inside_the_root() {
let base = std::env::temp_dir().join(format!("pdfcraft-automation-save-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let root = base.join("root");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let mut a = Automation::new().with_root(&root).unwrap();
let bytes = Arc::new(numbered_pages(1));
// Remembered path inside the root: still saves.
let inside = root.canonicalize().unwrap().join("in.pdf");
let id = a.session.open("in.pdf", Some(inside.to_string_lossy().into_owned()), bytes.clone(), None).unwrap();
json(&mut a, "doc_save", json!({ "doc": id.0 }));
assert!(inside.exists());
// Remembered path outside the root: refused, and nothing is written there.
let escaped = outside.join("out.pdf");
let id = a.session.open("out.pdf", Some(escaped.to_string_lossy().into_owned()), bytes, None).unwrap();
let error = a.call("doc_save", &json!({ "doc": id.0 })).unwrap_err();
assert!(error.to_string().contains("outside the allowed directory"), "{error}");
assert!(!escaped.exists());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() {
let base = std::env::temp_dir();
@@ -219,6 +321,7 @@ fn names_from_documents_become_one_plain_file_name() {
#[cfg(windows)]
#[test]
fn other_shares_and_device_paths_are_refused_by_name() {
use crate::file_access::foreign_share;
let local = Path::new(r"\\?\C:\work\root");
for p in [
r"\\host\share\x.pdf",
@@ -242,6 +345,28 @@ fn other_shares_and_device_paths_are_refused_by_name() {
assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared));
}
/// Windows drops a trailing dot from a folder name when it opens a path (`root.` is `root`), so
/// with a root, a path that names the root or a folder above it that way still reaches the file
/// inside. A dotted name outside stays outside.
#[cfg(windows)]
#[test]
fn a_trailing_dot_on_the_root_or_above_it_names_the_folder_windows_opens() {
let base = std::env::temp_dir().join(format!("pdfcraft-automation-dot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("root")).unwrap();
std::fs::create_dir_all(base.join("outside")).unwrap();
std::fs::write(base.join("root").join("a.pdf"), b"x").unwrap();
std::fs::write(base.join("outside").join("b.pdf"), b"x").unwrap();
let a = Automation::new().with_root(base.join("root")).unwrap();
let shown = base.to_string_lossy().into_owned();
for p in [format!(r"{shown}\root.\a.pdf"), format!(r"{shown}.\root\a.pdf")] {
let resolved = a.resolve(&p, false);
assert!(resolved.is_ok(), "{p}: {resolved:?}");
}
assert!(a.resolve(&format!(r"{shown}\outside.\b.pdf"), false).is_err());
let _ = std::fs::remove_dir_all(&base);
}
/// 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()));
+58 -28
View File
@@ -140,15 +140,15 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({
"files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}},
"start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."},
"prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."},
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."}
"prefix": {"type":"string","maxLength":128,"description":"Bates prefix, up to 128 characters; cannot contain # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Bates suffix, up to 128 characters; cannot contain # or >>."},
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false). Output collisions are checked conservatively: Windows compares case-insensitively, while Unix preserves case, so case-sensitive Windows and case-insensitive Unix volumes can differ from normal runs."}
}), &["files"])),
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
.with(schema(json!({
"paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},
"out_dir": {"type":"string"},
"steps": {"type":"array","items":{"type":"string","enum":["convert","ocr","optimize"]},"minItems":1,"maxItems":3,"uniqueItems":true},
"recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"},
"recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"}, "dry_run": {"type":"boolean"},
"ppi": {"type":"number","minimum":9,"maximum":1600},
"quality": {"type":"integer","minimum":1,"maximum":100},
"dpi": {"type":"number","minimum":72,"maximum":600},
@@ -195,21 +195,40 @@ pub fn tools() -> Vec<ToolDef> {
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
}), &["doc", "page"])),
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. orbit, in place of direction, frames the model as a viewer's orbit control does (the same size from every side) and applies, in order: view (iso, front, right, top or back; default iso) about up (+x, +y, +z, -x, -y or -z; default +z) with the Front view looking along front (an axis perpendicular to up; default +y, or -z when y is up), then turn [yaw, pitch] in degrees (pitch looks down and stops just short of the up axis), then zoom (a factor, the result held within 0.05 to 50) about the picture point zoom_at [right, up] in picture heights from the centre (default the centre), then pan [right, up] in model radii (it moves the target, so the model moves the other way). perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 },
"width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 },
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"perspective": { "type": "boolean" }
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3, "description": "The view direction from the eye towards the model (default [-1, 1, -1]). Mutually exclusive with orbit." },
"perspective": { "type": "boolean" },
"orbit": { "type": "object", "description": "A viewer's orbit moves, applied in the order of these keys; all optional. Mutually exclusive with direction.", "properties": {
"view": { "type": "string", "enum": ["iso", "front", "right", "top", "back"], "description": "The view to start from (default iso)." },
"up": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The model axis that appears upward (default +z)." },
"front": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The axis the Front view looks along, perpendicular to up (default +y, or -z when y is up)." },
"turn": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[yaw, pitch] in degrees, pitch looking down (default [0, 0])." },
"zoom": { "type": "number", "exclusiveMinimum": 0, "description": "A factor on the fitted view (default 1); the result is held within 0.05 to 50." },
"zoom_at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "The picture point the zoom keeps still, [right, up] in picture heights from the centre (default [0, 0]). Requires zoom." },
"pan": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "Moves the target [right, up] in model radii, so the model moves the other way (default [0, 0]); each held within 1000." }
}, "additionalProperties": false }
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list (or with all: true every object on the page) by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","offset"])),
t("object_copy", "Copy or cut objects together", "Edit ▸ Copy (cut: Cut) of 1–1000 mixed references from object_list, or with all: true every object on the page, as ⌘A then ⌘C select and copy them in Edit a PDF. The copy keeps their content operators and every resource they use (fonts, images, ExtGStates, colour spaces, patterns, shadings) and is kept for object_paste into any open document, also after this one changes or closes. cut then deletes them as one undo step. Refused when the document doesn't allow copying (cut: or changes), or when text used as a clipping path is drawn before them. Returns the count copied and their text.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
"cut":{"type":"boolean","description":"Delete them after copying (one undo step)."},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page"])),
t("object_paste", "Paste copied objects", "Edit ▸ Paste of what object_copy copied, onto page of doc as page content that draws the same: where it was, or on the page it came from 18 points down and to the right, and 18 more for each further paste onto the same page; centred when that would leave it off the page. Resources join the page's under names that don't clash; into another document the fonts, images and other objects they use are copied (each once, sharing what is there; refused past 20,000 objects or 256 MB). Pasted content is untagged and in no layer. One undo step.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.")
.ro()
.cmd("file.word_count")
@@ -227,12 +246,13 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.")
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation. With stop_at_limit the search ends at the limit-th match instead of looking for one more, so a lone early match doesn't read the rest of the document.")
.ro()
.cmd("edit.find")
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." },
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])),
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." },
"stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])),
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
.cmd("page.rotate")
.with(schema(
@@ -483,7 +503,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
@@ -620,7 +640,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, more than 10000 matches on one page fail the call, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
@@ -632,6 +652,8 @@ pub fn tools() -> Vec<ToolDef> {
"case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." },
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": pattern_enum(),
"regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax), at most 1024 bytes (maxLength counts characters, the limit is bytes); every match is marked." },
"validate": { "type": "string", "enum": ["luhn", "mod97", "iban"], "description": "With regex: keep a match only if its checksum is valid (spaces and hyphens ignored)." },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
"overlay": { "type": "string" },
@@ -686,7 +708,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_print",
"Print",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
)
.with(schema(
json!({
@@ -697,6 +719,8 @@ pub fn tools() -> Vec<ToolDef> {
"layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] },
"scale": { "type": "number", "exclusiveMinimum": 0 },
"per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 },
"cols": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: columns across the sheet; give rows too, and no per_sheet." },
"rows": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: rows down the sheet; give cols too, and no per_sheet." },
"order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] },
"border": { "type": "boolean" },
"auto_rotate": { "type": "boolean" },
@@ -789,7 +813,7 @@ pub fn tools() -> Vec<ToolDef> {
t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral.")
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
@@ -797,22 +821,24 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
&["doc", "page", "index"],
)),
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page, shown at zoom: fit_page (default), fit_width, fit_height, fit_visible, actual, inherit (keep the reader's zoom) or a percentage. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
.cmd("edit.link")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 },
"zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" },
"visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] },
}),
&["doc", "page", "rect"],
)),
t("link_edit", "Edit a link", "Change a link's rect, destination (url or to_page) or appearance. Undoable.")
t("link_edit", "Edit a link", "Change a link's rect, destination (url, or to_page and zoom as in link_add; a new to_page keeps the link's zoom, a zoom alone keeps its page) or appearance. Undoable.")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 },
"zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" },
"visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] },
}),
&["doc", "page", "index"],
@@ -852,7 +878,7 @@ pub fn tools() -> Vec<ToolDef> {
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Thai in a text box or callout is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Other text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
)
.with(schema(
json!({
@@ -896,7 +922,7 @@ pub fn tools() -> Vec<ToolDef> {
comment_ref(json!({ "status": { "type": "string", "enum": ["none", "accepted", "rejected", "cancelled", "completed"] }, "author": { "type": "string" } })),
&["doc", "status"],
)),
t("sign_list", "List and validate signatures", "Every signature field with its validation: status (valid, unknown = intact but the signer isn't trusted, invalid, unsigned), signer and certificate, date, reason, location, certification level, page and rect, the revision it covers and changes made after signing (none, allowed, disallowed), with Acrobat-style explanations.")
t("sign_list", "List and validate signatures", "Every signature field with its validation: status (valid, unknown = intact but the signer isn't trusted, invalid, unsigned), signer and certificate, date, reason, location, certification level, page and rect, whether PdfCraft draws that state into the signature's own appearance as displayed (verdict_shown; never written to the file), the revision it covers and changes made after signing (none, allowed, disallowed), with Acrobat-style explanations.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("sign_id_create", "Create a digital ID", "Create a self-signed digital ID and save it as a password-protected .p12 file (Acrobat: Configure a new digital ID ▸ Create a new digital ID ▸ Save to file). Default key: 2048-bit RSA, valid 5 years.").with(schema(
@@ -1123,7 +1149,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>> <<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.",
"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; Thai is drawn from PdfCraft's built-in Thai face beside it (a build without one refuses it). replace: true swaps out existing ones (Update). Undoable.",
)
.cmd("edit.header_footer")
.with(schema(
@@ -1142,7 +1168,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given (Thai drawn from PdfCraft's built-in Thai face; a build without one refuses it), on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
.cmd("edit.watermark")
.with(schema(
json!({
@@ -1290,7 +1316,7 @@ pub fn tools() -> Vec<ToolDef> {
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that a damaged or undecodable content stream may hide or change are rejected. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
@@ -1325,7 +1351,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses inferred tables, bordered only along the grid lines the page draws, and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
@@ -1477,7 +1503,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed Thai is drawn from PdfCraft's built-in Thai face (a build without one refuses it); other typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@@ -1560,14 +1586,14 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, ungroup (Form artwork only: its content is drawn in place, so its text becomes editable with text_paragraphs/text_edit and its pictures separate; refused for knockout groups and for transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's, which would composite differently, and for forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share, which would stop remapping their colours), or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"image": { "type": "integer", "minimum": 1 },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop", "ungroup"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
@@ -1586,9 +1612,12 @@ pub fn tools() -> Vec<ToolDef> {
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources and bundled choices can be selected by text_edit.font_resource. builtin:thai explicitly replaces a paragraph or range with the bundled Noto Sans Thai; a build without that face never lists it and text_edit refuses it there (a page whose fonts fill the listing budget may also omit it, but text_edit still accepts it). preview_only faces cannot be selected.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_prepare", "Prepare grouped text", "Make existing text inside supported Form XObjects directly editable on one page. Replaces placements with their original operators and resources, preserving position, clipping and other pages. Refuses unsafe transparency, tags or malformed content. Adds no text or cover boxes. Returns prepared_groups (placements made editable), repaired_fonts (damaged legacy Thai maps repaired) and refused_groups ('name: reason' for each group left as it was). Also repairs the page's own damaged legacy Thai maps, grouped or not. Fails when the page has nothing to prepare or repair. Undoable; list text_paragraphs again afterwards.")
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.")
.ro()
.cmd("edit.edit_text")
@@ -1602,7 +1631,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
"paragraph": { "type": "integer", "minimum": 1 },
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
"font_resource": { "type": "string", "description": "Paragraphs: source/full page resource or bundled document-font choice from text_fonts (builtin:thai selects the bundled Thai face); also accepts text_font_import resources." },
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
"text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
@@ -1633,7 +1662,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1645,6 +1674,7 @@ pub fn tools() -> Vec<ToolDef> {
"color": { "type": "object" },
"gray": { "type": "object" },
"mono": { "type": "object" },
"target_bytes": { "type": "integer", "minimum": 1 },
"discard_thumbnails": { "type": "boolean" },
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
File diff suppressed because it is too large Load Diff
+58
View File
@@ -238,3 +238,61 @@ fn mcp_batch_errors_keep_the_structured_per_file_report() {
assert_eq!(report["result"]["isError"], true, "{report}");
assert_eq!(report["result"]["structuredContent"]["failed"], 1);
}
#[test]
fn dry_run_planned_files_are_not_counted_as_skipped() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir_all(dir.path().join("out")).unwrap();
let original = pdf();
std::fs::write(dir.path().join("input/a.pdf"), &original).unwrap();
std::fs::write(dir.path().join("input/sub/a.pdf"), &original).unwrap();
std::fs::write(dir.path().join("out/a.pdf"), b"precious").unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["optimize"],"out_dir":"out","dry_run":true}));
assert_eq!(report["dry_run"], true, "{report}");
assert_eq!(report["completed"], 0, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(report["skipped"], 0, "planned files are not skipped: {report}");
let files = report["files"].as_array().unwrap();
assert_eq!(files.len(), 2, "{report}");
assert!(files.iter().all(|file| file["status"] == "planned"), "{report}");
assert!(files.iter().all(|file| file["steps"] == json!(["optimize"])), "planned files list the ordered steps: {report}");
assert!(files[0]["output"].as_str().unwrap().ends_with("out/a (2).pdf"), "existing output gets the publish collision name: {report}");
assert!(files[1]["output"].as_str().unwrap().ends_with("out/a (3).pdf"), "later planned files reserve their names: {report}");
assert_eq!(std::fs::read(dir.path().join("out/a.pdf")).unwrap(), b"precious");
assert!(!dir.path().join("out/a (2).pdf").exists(), "dry run writes nothing: {report}");
#[cfg(unix)]
{
// An output subfolder that resolves outside out_dir is a per-file error:
// it counts as failed, honors stop_on_error, and later files are skipped.
for outside_root in [false, true] {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir_all(dir.path().join("out")).unwrap();
std::fs::write(dir.path().join("input/a.pdf"), pdf()).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
std::fs::write(dir.path().join("input/z.pdf"), pdf()).unwrap();
let target = if outside_root { outside.path().to_path_buf() } else { dir.path().join("outside") };
if !outside_root {
std::fs::create_dir(&target).unwrap();
}
std::os::unix::fs::symlink(&target, dir.path().join("out/sub")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(
&mut auto,
json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":true,"dry_run":true,"stop_on_error":true}),
);
assert_eq!(report["failed"], 1, "preview target failure is per-file: {report}");
assert_eq!(report["stopped"], true, "{report}");
assert_eq!(report["skipped"], 1, "only the unprocessed file is skipped: {report}");
let files = report["files"].as_array().unwrap();
assert_eq!(files.len(), 3, "{report}");
assert_eq!(files[0]["status"], "planned", "{report}");
assert_eq!(files[1]["status"], "error", "{report}");
assert_eq!(files[2]["status"], "skipped", "stop_on_error leaves later files unplanned: {report}");
assert!(!dir.path().join("out/a.pdf").exists(), "dry run writes nothing: {report}");
}
}
}
+168
View File
@@ -110,3 +110,171 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() {
let doc = open(&mut a, "prc.pdf");
assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok());
}
#[test]
fn an_orbit_frames_the_model_from_a_named_view_and_its_moves() {
let (_dir, mut a) = setup("orbit");
let doc = open(&mut a, "prc.pdf");
let draw = |a: &mut Automation, orbit: Value| orbit_picture(a, doc, false, &orbit);
// The triangle lies in z = 0: the Top view looks down on its face, the Front view along its edge.
let top = draw(&mut a, json!({ "view": "top" }));
assert_eq!(rgb(&top, 32, 24), [190, 192, 200], "the top view sees the face head on");
let front = draw(&mut a, json!({ "view": "front" }));
assert!((0..48).all(|y| rgb(&front, 32, y) == [255, 255, 255]), "the front view sees only its edge");
let tipped = draw(&mut a, json!({ "view": "front", "turn": [0, 30] }));
assert_ne!(rgb(&tipped, 32, 24), [255, 255, 255], "tipped down from the front, the face is in the middle");
// With y up the Front view looks along -z, onto the face.
let y_up = draw(&mut a, json!({ "view": "front", "up": "+y" }));
assert_eq!(rgb(&y_up, 32, 24), [190, 192, 200]);
// Turning the front view down by 90° stops just short of the top view and sees the face.
let turned = draw(&mut a, json!({ "view": "front", "turn": [0, 90] }));
assert_ne!(rgb(&turned, 32, 24), [255, 255, 255]);
// A pan moves the target right, so the model moves left; three radii take it out of the picture.
let (left, right) = extent(&top, 24);
let (moved_left, moved_right) = extent(&draw(&mut a, json!({ "view": "top", "pan": [0.5, 0] })), 24);
assert!(moved_left < left && moved_right < right, "{left}..{right} moved to {moved_left}..{moved_right}");
let panned = draw(&mut a, json!({ "view": "top", "pan": [3, 0] }));
assert!((0..64).all(|x| rgb(&panned, x, 24) == [255, 255, 255]), "the model has left the picture");
// Zooming in about the centre keeps the face under it and leaves no background at the corner.
let zoomed = draw(&mut a, json!({ "view": "top", "zoom": 8 }));
assert_eq!(rgb(&zoomed, 32, 24), [190, 192, 200]);
assert_ne!(rgb(&zoomed, 0, 0), [255, 255, 255], "zoomed in, the face fills the corner too");
}
/// The first and last columns on row `y` that are not background.
fn extent(data: &[u8], y: usize) -> (usize, usize) {
let drawn: Vec<usize> = (0..64).filter(|&x| rgb(data, x, y) != [255, 255, 255]).collect();
(*drawn.first().expect("the model is on the row"), *drawn.last().expect("the model is on the row"))
}
/// The first and last rows on column `x` that are not background.
fn rows(data: &[u8], x: usize) -> (usize, usize) {
let drawn: Vec<usize> = (0..48).filter(|&y| rgb(data, x, y) != [255, 255, 255]).collect();
(*drawn.first().expect("the model is on the column"), *drawn.last().expect("the model is on the column"))
}
/// A 64 by 48 picture of the model through `orbit`.
fn orbit_picture(a: &mut Automation, doc: u64, perspective: bool, orbit: &Value) -> Vec<u8> {
let out =
a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "perspective": perspective, "orbit": orbit })).unwrap();
let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") };
data.clone()
}
#[test]
fn an_empty_orbit_frames_the_isometric_view_in_perspective() {
let (_dir, mut a) = setup("orbit-default");
let doc = open(&mut a, "prc.pdf");
let default = orbit_picture(&mut a, doc, false, &json!({}));
assert_ne!(rgb(&default, 32, 24), [255, 255, 255], "the model is centred");
let (left, right) = extent(&default, 24);
let (high, low) = rows(&default, 32);
assert!(left > 0 && right < 63 && high > 0 && low < 47, "inside the picture: {left}..{right}, {high}..{low}");
assert!(right - left > 16, "and fills it: {left}..{right}");
assert_eq!(default, orbit_picture(&mut a, doc, false, &json!({ "view": "iso", "up": "+z", "front": "+y" })), "the defaults");
let unset = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "orbit": {} })).unwrap();
let Content::Png { data: unset, .. } = &unset[0] else { panic!("expected a PNG") };
assert_eq!(*unset, orbit_picture(&mut a, doc, true, &json!({})), "perspective is the default");
assert_ne!(*unset, default, "and shows");
}
#[test]
fn zoom_at_keeps_the_point_under_it_still() {
let (_dir, mut a) = setup("orbit-zoom-at");
let doc = open(&mut a, "prc.pdf");
let (left, right) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2 })), 24);
// A point 0.2 picture heights off centre stays there, so what was at the centre moves
// 0.2 x (2 - 1) x 48 = 9.6 pixels the other way.
for (at, sign) in [(0.2, -1.0), (-0.2, 1.0)] {
let (l, r) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2, "zoom_at": [at, 0] })), 24);
for (was, now) in [(left, l), (right, r)] {
let moved = now as f64 - was as f64;
assert!((moved - sign * 9.6).abs() <= 1.5, "at {at}: {was} moved to {now}");
}
}
}
#[test]
fn the_zoom_is_held_within_its_range() {
let (_dir, mut a) = setup("orbit-zoom-range");
let doc = open(&mut a, "prc.pdf");
let far = orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.05 }));
assert_eq!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.001 })), "the far end");
assert_ne!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.2 })));
// Panned to just inside the near edge, so a nearer view would move the edge in the picture.
let close = |z: f64| json!({ "view": "top", "zoom": z, "pan": [0, -0.7] });
let near = orbit_picture(&mut a, doc, false, &close(50.0));
assert_eq!(near, orbit_picture(&mut a, doc, false, &close(1000.0)), "the near end");
assert_ne!(near, orbit_picture(&mut a, doc, false, &close(25.0)));
}
#[test]
fn an_orbit_draws_in_perspective_at_the_same_scale() {
let (_dir, mut a) = setup("orbit-perspective");
let doc = open(&mut a, "prc.pdf");
let tipped = json!({ "view": "front", "turn": [0, 30] });
let deep = orbit_picture(&mut a, doc, true, &tipped);
assert_ne!(rgb(&deep, 32, 24), [255, 255, 255]);
assert_ne!(deep, orbit_picture(&mut a, doc, false, &tipped), "foreshortened");
let top = json!({ "view": "top" });
let (left, right) = extent(&orbit_picture(&mut a, doc, true, &top), 24);
let (flat_left, flat_right) = extent(&orbit_picture(&mut a, doc, false, &top), 24);
assert!(left.abs_diff(flat_left) <= 2 && right.abs_diff(flat_right) <= 2, "{left}..{right} against {flat_left}..{flat_right}");
}
#[test]
fn an_orbit_front_yaw_and_upward_pan_each_move_the_picture() {
let (_dir, mut a) = setup("orbit-moves");
let doc = open(&mut a, "prc.pdf");
let mut draw = |orbit: Value| orbit_picture(&mut a, doc, false, &orbit);
// Yawing the Front view by 90 degrees is the Right view, which looks along -x; +x is its opposite.
let right_view = draw(json!({ "view": "right", "turn": [0, 30] }));
assert_eq!(right_view, draw(json!({ "view": "front", "turn": [90, 30] })), "yaw");
assert_eq!(right_view, draw(json!({ "view": "front", "front": "-x", "turn": [0, 30] })), "front -x");
assert_ne!(right_view, draw(json!({ "view": "front", "turn": [0, 30] })));
assert_eq!(draw(json!({ "view": "right", "turn": [180, 30] })), draw(json!({ "view": "front", "front": "+x", "turn": [0, 30] })), "front +x");
// From above, the triangle's apex is up the picture; a quarter yaw lays it along the row,
// twice as wide there.
let top = draw(json!({ "view": "top" }));
let (left, right) = extent(&top, 24);
let (yaw_left, yaw_right) = extent(&draw(json!({ "view": "top", "turn": [90, 0] })), 24);
assert!(yaw_right - yaw_left > (right - left) * 3 / 2, "{left}..{right} yawed to {yaw_left}..{yaw_right}");
// A pan up moves the target up, so the model moves down.
let (high, low) = rows(&top, 32);
let (moved_high, moved_low) = rows(&draw(json!({ "view": "top", "pan": [0, 0.25] })), 32);
assert!(moved_high > high && moved_low >= low, "{high}..{low} moved to {moved_high}..{moved_low}");
}
#[test]
fn a_wrong_orbit_is_a_clear_error() {
let (_dir, mut a) = setup("orbit-bad");
let doc = open(&mut a, "prc.pdf");
for (bad, reason) in [
(json!({ "orbit": { "view": "side" } }), "view as one of"),
(json!({ "orbit": { "view": 1 } }), "view as a string"),
(json!({ "orbit": { "up": "z" } }), "up as one of"),
(json!({ "orbit": { "up": 1 } }), "up as a string"),
(json!({ "orbit": { "front": "y" } }), "front as one of"),
(json!({ "orbit": { "up": "+z", "front": "-z" } }), "front perpendicular to up"),
(json!({ "orbit": { "turn": [1] } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "turn": [1, 2, 3] } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "turn": 1 } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "zoom": 0 } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom": -2 } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom": "a" } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom_at": [0.1, 0] } }), "with zoom when it has zoom_at"),
(json!({ "orbit": { "zoom": 2, "zoom_at": [1] } }), "zoom_at as [right, up]"),
(json!({ "orbit": { "zoom": 2, "zoom_at": [1, 2, 3] } }), "zoom_at as [right, up]"),
(json!({ "orbit": { "pan": [1, "a"] } }), "pan as [right, up]"),
(json!({ "orbit": { "pan": [1] } }), "pan as [right, up]"),
(json!({ "orbit": { "pan": [1, 2, 3] } }), "pan as [right, up]"),
(json!({ "orbit": { "spin": 1 } }), "without spin"),
(json!({ "orbit": 1 }), "an object"),
(json!({ "orbit": {}, "direction": [0, 0, -1] }), "left out when direction is given"),
] {
let mut args = json!({ "doc": doc, "page": 1, "width": 16, "height": 16 });
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
let err = a.call("model_render", &args).unwrap_err().to_string();
assert!(err.contains("orbit must be") && err.contains(reason), "{bad}: {err}");
}
}
+38 -3
View File
@@ -411,6 +411,12 @@ mod tests {
}
}
/// Byte offset of pixel (`x`, `y`) in a `w`-wide RGBA buffer, in wide integers so an image past 4 GiB
/// (its `y * w` or `* 4` would wrap a `u32`) still indexes the right pixel; past `usize` it is out of range.
fn rgba_index(x: u32, y: u32, w: u32) -> usize {
usize::try_from((u128::from(y) * u128::from(w) + u128::from(x)) * 4).unwrap_or(usize::MAX)
}
/// Visual compare: the regions where two renderings of a page differ, as pixel boxes
/// `[x0, y0, x1, y1]` (y down) in `a`'s image. Both images are RGBA; `b` is sampled at the
/// same relative position when sizes differ. Pixels count as different when a channel differs
@@ -421,6 +427,11 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8)
if wa == 0 || ha == 0 || wb == 0 || hb == 0 {
return Vec::new();
}
// Declared sizes the buffers cannot hold (a damaged render) would overflow the u32 index maths.
let holds = |px: &[u8], w: u32, h: u32| u128::from(w) * u128::from(h) * 4 <= px.len() as u128;
if !holds(pa, wa, ha) || !holds(pb, wb, hb) {
return Vec::new();
}
const CELL: u32 = 8;
let (gw, gh) = (wa.div_ceil(CELL), ha.div_ceil(CELL));
let mut grid = vec![false; (gw * gh) as usize];
@@ -428,9 +439,8 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8)
let yb = (y as u64 * hb as u64 / ha as u64) as u32;
for x in 0..wa {
let xb = (x as u64 * wb as u64 / wa as u64) as u32;
let ia = ((y * wa + x) * 4) as usize;
let ib = ((yb * wb + xb) * 4) as usize;
let (Some(ca), Some(cb)) = (pa.get(ia..ia + 3), pb.get(ib..ib + 3)) else { continue };
let (ia, ib) = (rgba_index(x, y, wa), rgba_index(xb, yb, wb));
let (Some(ca), Some(cb)) = (pa.get(ia..ia.saturating_add(3)), pb.get(ib..ib.saturating_add(3))) else { continue };
if ca.iter().zip(cb).any(|(p, q)| p.abs_diff(*q) > tolerance) {
grid[((y / CELL) * gw + x / CELL) as usize] = true;
}
@@ -500,3 +510,28 @@ mod visual_tests {
assert_eq!(visual_regions((&a, 200, 200), (&c, 200, 200), 16).len(), 2);
}
}
#[cfg(test)]
mod visual_hostile_tests {
use super::*;
#[test]
fn absurd_dimensions_with_short_buffers_return_quickly() {
let px = [0u8; 16];
let r = visual_regions((&px, u32::MAX, u32::MAX), (&px, 2, 2), 0);
assert!(r.is_empty());
}
#[test]
fn pixel_offsets_past_four_gib_do_not_wrap() {
// A 32768 x 32769 RGBA image is 4,295,098,368 bytes, so a buffer that large passes the size check;
// its last pixel sits past u32::MAX and must not wrap back onto an early pixel.
let (w, h) = (32768u32, 32769u32);
let last = rgba_index(w - 1, h - 1, w);
assert_eq!(last as u64, (u64::from(h - 1) * u64::from(w) + u64::from(w - 1)) * 4);
assert!(last as u64 > u64::from(u32::MAX));
assert_eq!(rgba_index(0, 0, w), 0);
// Out of range for any buffer: saturates instead of overflowing.
assert_eq!(rgba_index(u32::MAX, u32::MAX, u32::MAX), usize::MAX);
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ let doc = Document::open(bytes)?; // Arc<Vec<u8>>
let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files
let root = doc.root(); // Option<ObjRef> — the catalog
let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc<Object>
let real = doc.resolve(&obj); // follow one indirect reference
let obj = doc.get(ObjRef::new(12, 0)); // load (and cache): Arc<Object>
let real = doc.resolve(&obj); // follow a bounded reference chain
let d = doc.dict(&obj); // Option<Dict>, resolving first
let data = stream.decoded()?; // filters applied on demand
let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
+226 -36
View File
@@ -11,7 +11,9 @@ use crate::{CosError, Document, ObjRef, Object, Stream};
/// 16 ns, so this is 2 to 5 minutes, and about as long again for what an operation does with the
/// pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again to
/// decode them, so this alone reads about 1,000 of them. A byte into or out of a filter is a step
/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit.
/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit. A
/// byte Flate reads is [`pdfcraft_filters::FLATE_READ_WORK`] steps besides, since inflating blocks
/// that make nothing takes up to 21 ns a byte: about 7 ns a step.
const IMAGE_WORK: u64 = 1 << 34;
/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a
/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored
@@ -38,9 +40,9 @@ const JPEG_WORK: u64 = 2;
/// and images can share one, so reading it again for each has to count.
const JPEG_READ_WORK: u64 = 4;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or
/// the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data and
/// whether it stopped at damage, or the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)>;
/// What one operation over a document's images may still spend decoding them: exporting them,
/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction.
@@ -207,6 +209,11 @@ impl ImageBudget {
self.refused
}
/// Charges `work` steps paid for out of another allowance, by a thread working on its own.
pub fn charge(&mut self, work: u64) {
self.work = self.work.saturating_sub(work);
}
/// The steps of decoding still allowed.
pub fn work_left(&self) -> u64 {
self.work
@@ -263,45 +270,57 @@ impl DecodeBudget {
}
/// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
/// decoding, once the allowance is spent.
/// decoding, once the allowance is spent. A stream damaged partway gives what decoded before
/// the damage, so code that writes the stream back reads it with
/// [`DecodeBudget::decode_with_damage`] instead.
pub fn decode(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_within_counted)
self.charge(stream, usize::MAX, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding
/// past `max` bytes is an error that charges `max`, the work it took to find out.
pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
self.charge(stream, max, Stream::decoded_within_counted)
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
/// A stream written back from them loses everything after the damage, which other readers may
/// still read.
pub fn decode_with_damage(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<(Vec<u8>, bool), CosError> {
self.charge(&doc.decoding_view(stream), max, Stream::decoded_within_counted)
}
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
/// the allowance.
pub fn decode_strict(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted)
/// the allowance, with its `/DecodeParms` given by reference read in `doc`.
pub fn decode_strict(&mut self, doc: &Document, stream: &Stream) -> Result<Vec<u8>, CosError> {
let strict: Decoder = |s, max| s.decoded_strict_within_counted(max).map(|(data, spent)| (data, false, spent));
self.charge(&doc.decoding_view(stream), usize::MAX, strict).map(|(data, _)| data)
}
/// Decodes `stream` within `max` and the allowance, charging what every filter stage
/// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream
/// pays for the bytes decoded before the error, so referencing a stream again and again
/// spends the allowance by the work each decode does.
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<Vec<u8>, CosError> {
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec<u8>, bool), CosError> {
if self.bytes == 0 && !stream.raw.is_empty() {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
if max < self.bytes {
let result = decode(stream, max);
let spent = match &result {
Ok((_, spent)) => *spent,
Ok((_, _, spent)) => *spent,
Err((FilterError::LimitExceeded(_), _)) => max,
Err((_, spent)) => *spent,
};
self.bytes = self.bytes.saturating_sub(spent);
return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string()));
return result.map(|(data, damaged, _)| (data, damaged)).map_err(|(e, _)| CosError::Filter(e.to_string()));
}
match decode(stream, self.bytes) {
Ok((data, spent)) => {
Ok((data, damaged, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Ok(data)
Ok((data, damaged))
}
Err((FilterError::LimitExceeded(_), _)) => {
self.bytes = 0;
@@ -332,17 +351,24 @@ impl DecodeBudget {
self.seen.insert(r)
}
/// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
/// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
/// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
/// place, so the caller chooses between skipping it and giving up.
/// Each stream `contents` names, decoded against the allowance, in order, its `/DecodeParms`
/// given by reference read in `doc`. `contents` is a page's `/Contents`: one stream, or an
/// array whose non-stream entries are left out. A stream that fails to decode, or arrives
/// after the allowance is spent, is an `Err` in its place, so the caller chooses between
/// skipping it and giving up.
pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec<Result<Vec<u8>, CosError>> {
self.decode_contents_with_damage(doc, contents).into_iter().map(|read| read.map(|(data, _)| data)).collect()
}
/// [`DecodeBudget::decode_contents`], each stream also saying whether it is damaged partway
/// ([`DecodeBudget::decode_with_damage`], which reads `/DecodeParms` given by reference).
pub fn decode_contents_with_damage(&mut self, doc: &Document, contents: &Object) -> Vec<Result<(Vec<u8>, bool), CosError>> {
match &*doc.resolve(contents) {
Object::Stream(s) => vec![self.decode(s)],
Object::Stream(s) => vec![self.decode_with_damage(doc, s, usize::MAX)],
Object::Array(list) => list
.iter()
.filter_map(|entry| match &*doc.resolve(entry) {
Object::Stream(s) => Some(self.decode(s)),
Object::Stream(s) => Some(self.decode_with_damage(doc, s, usize::MAX)),
_ => None,
})
.collect(),
@@ -367,6 +393,11 @@ mod image_tests {
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())
}
/// What a Flate stage pays to read `n` bytes, besides what it makes.
fn read(n: usize) -> u64 {
pdfcraft_filters::FLATE_READ_WORK * n as u64
}
/// Each image spends what it took from one allowance, and those past it are refused and
/// counted, whatever their filters. What can't be decoded here costs nothing, and fails
/// without being one of these.
@@ -413,7 +444,7 @@ mod image_tests {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("FlateDecode")]));
let bomb = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&inner));
let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64;
let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64 + read(bomb.raw.len()) + read(inner.len());
let mut budget = ImageBudget::with_work(2 * cost + 1);
for left in [cost + 1, 1] {
assert_eq!(budget.decode(&doc, &bomb, 1 << 20).unwrap(), samples);
@@ -442,17 +473,17 @@ mod image_tests {
let image = behind(pdfcraft_filters::encode_flate(&alone.raw));
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64);
assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64 + read(image.raw.len()));
// Past this image's limit: an error, with what finding out took paid and the rest left.
let bomb = behind(pdfcraft_filters::encode_flate(&vec![0; 1 << 16]));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &bomb, 1000).is_err());
assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + 1000);
assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + read(bomb.raw.len()) + 1000);
assert_eq!(budget.refused(), 0, "that image's limit isn't the run's");
// A small allowance is a limit too: it stops there, and the image is refused.
let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + 10);
let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + read(bomb.raw.len()) + 10);
assert!(budget.decode(&doc, &bomb, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
@@ -492,7 +523,7 @@ mod image_tests {
let doc = Document::open(std::sync::Arc::new(file)).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let s = stream(&doc, image);
let filters = (raw.len() + skipped.len()) as u64;
let filters = (raw.len() + skipped.len()) as u64 + read(raw.len());
assert_eq!(spent(&doc, &s), first, "the first read undoes them");
assert_eq!(spent(&doc, &s), first - filters, "kept");
@@ -507,7 +538,7 @@ mod image_tests {
let image = jbig2(Some(Object::Dict(parms)));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &image, 1 << 20).unwrap_err().to_string().contains("globals"));
assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64);
assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64 + read(over.len()));
assert_eq!(budget.refused(), 0);
}
@@ -528,7 +559,7 @@ mod image_tests {
dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)]));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded));
let pixels = u64::from(w) * u64::from(h);
let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + pixels;
let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + read(image.raw.len()) + pixels;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize);
assert_eq!(budget.work_left(), 0, "exactly that");
@@ -553,7 +584,7 @@ mod image_tests {
};
let rows = vec![0xFFu8; 512 * 1024];
let flate = blank(Some("FlateDecode"), pdfcraft_filters::encode_flate(&rows));
let cost = (flate.raw.len() + rows.len()) as u64 + 4096 * 1024;
let cost = (flate.raw.len() + rows.len()) as u64 + read(flate.raw.len()) + 4096 * 1024;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &flate, 1 << 24).unwrap(), rows);
assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that");
@@ -590,6 +621,36 @@ mod image_tests {
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// What Flate reads is priced for blocks that make nothing, and an image an encoder made pays
/// little for it: a scan-like picture stored in under half its size costs no more than half
/// again its bytes, samples and pixels. Redaction decodes a picture once for each page that draws it, so
/// a letterhead on hundreds of pages has to fit about as often as it did unpriced (here, at
/// most half again the cost).
#[test]
fn an_image_an_encoder_made_pays_little_for_its_reading() {
let doc = Document::new_empty();
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let samples: Vec<u8> = (0..256 * 256usize)
.map(|i| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
((i % 256) as u8 / 16) ^ (seed as u8 & 3)
})
.collect();
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(256));
dict.set(b"Height".to_vec(), Object::Int(256));
let image = Stream::flate(dict, &samples);
assert!(image.raw.len() * 2 < samples.len(), "stored smaller: {}", image.raw.len());
let unpriced = (image.raw.len() + samples.len()) as u64 + 256 * 256;
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, &image, 1 << 20).unwrap();
let paid = u64::MAX - budget.work_left();
assert_eq!(paid, unpriced + read(image.raw.len()));
assert!(2 * paid <= 3 * unpriced, "{paid} steps against {unpriced}");
}
/// An image pays for the pixels its data holds, not those its size declares: one claiming
/// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the
/// images after it are decoded as they would be without it.
@@ -609,8 +670,8 @@ mod image_tests {
budget.decode(&doc, s, 1 << 24).unwrap();
u64::MAX - budget.work_left()
};
assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them");
assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels");
assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64 + read(claims.raw.len()), "its bytes, and a pixel a bit of them");
assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + read(image.raw.len()) + 64 * 64, "its bytes, and its pixels");
for decode in [ImageBudget::decode, ImageBudget::decode_filters] {
let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image));
assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]);
@@ -627,7 +688,7 @@ mod image_tests {
let samples = vec![7u8; 1000];
let raw = pdfcraft_filters::encode_flate(&samples);
let chain = [(Filter::Flate, Params::default())];
let cost = (raw.len() + samples.len()) as u64;
let cost = (raw.len() + samples.len()) as u64 + read(raw.len());
let mut budget = ImageBudget::with_work(cost + 10);
assert_eq!(budget.decode_chain(&chain, &raw, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), 10);
@@ -664,7 +725,7 @@ mod image_tests {
assert_eq!(budget.decode_filters(&doc, &image, 1 << 20).unwrap(), rows);
let cost = u64::MAX - budget.work_left();
// The tag byte of each of its 100 rows counts, which the predictor takes off.
assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64);
assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64 + read(image.raw.len()));
let mut budget = ImageBudget::with_work(cost - 1);
assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
@@ -692,6 +753,38 @@ mod image_tests {
assert_eq!(budget.work_left(), u64::MAX);
}
/// PDFBOX-3932: an image and soft mask declared `/Predictor 15` that their writer never
/// applied, so their rows carry no PNG filter-type byte. Exported, they were "shorter than
/// their size says"; they are read as the samples they are, and tagged rows as before.
#[test]
fn an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples() {
let doc = Document::new_empty();
let samples: Vec<u8> = (0..36u8).map(|i| 200 - i).collect();
let image = |height: Option<i64>, raw: Vec<u8>| {
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(15));
parms.set(b"Colors".to_vec(), Object::Int(3));
parms.set(b"Columns".to_vec(), Object::Int(3));
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(3));
if let Some(h) = height {
dict.set(b"Height".to_vec(), Object::Int(h));
}
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
Stream::from_raw(dict, raw)
};
let untagged = pdfcraft_filters::encode_flate(&samples);
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image(Some(4), untagged.clone()), 1 << 20).unwrap(), samples);
// Without a height to tell, the predictor is undone, as before.
assert_ne!(budget.decode(&doc, &image(None, untagged.clone()), 1 << 20).ok(), Some(samples.clone()));
assert_ne!(budget.decode(&doc, &image(Some(5), untagged), 1 << 20).ok(), Some(samples.clone()));
let p = pdfcraft_filters::Params { predictor: 15, colors: 3, columns: 3, ..pdfcraft_filters::Params::default() };
let tagged = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &samples).unwrap();
assert_eq!(budget.decode(&doc, &image(Some(4), tagged), 1 << 20).unwrap(), samples);
}
/// The images of a file share its globals, so an image refused doesn't read them: this one's
/// can't be read, which is how to tell.
#[test]
@@ -769,7 +862,7 @@ mod tests {
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
assert!(budget.decode_strict(stream).is_err());
assert!(budget.decode_strict(&doc, stream).is_err());
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new(10 << 20, 1);
@@ -793,14 +886,111 @@ mod tests {
let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec());
let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0);
assert!(partial > 0, "a cut flate stream decodes in part");
let doc = Document::new_empty();
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_strict(&corrupt).is_err());
assert!(budget.decode_strict(&doc, &corrupt).is_err());
assert_eq!(budget.remaining(), (10 << 20) - partial);
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_within(&corrupt, 1 << 30).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - partial);
}
/// A stream damaged partway decodes to what came before the damage, and says so, so that code
/// writing it back can leave it as it is. What every reader takes is no damage: a missing
/// checksum, no filter at all.
#[test]
fn a_stream_damaged_partway_says_so() {
let doc = Document::new_empty();
let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(&doc, s, max).map(|(d, damaged)| (d.len(), damaged)).ok();
let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]);
let cut = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() / 2).unwrap_or_default().to_vec());
let partial = cut.decoded().map(|d| d.len()).unwrap_or(0);
assert!(partial > 0, "a cut flate stream decodes in part");
let mut budget = DecodeBudget::new(10 << 20, 1);
assert_eq!(read(&mut budget, &cut, usize::MAX), Some((partial, true)));
assert_eq!(budget.remaining(), (10 << 20) - partial);
// Within a bound of its own, under what is left.
assert_eq!(read(&mut budget, &cut, 4 << 20), Some((partial, true)));
assert_eq!(budget.decode(&cut).map(|d| d.len()).ok(), Some(partial));
let mut budget = DecodeBudget::new(10 << 20, 1);
assert_eq!(read(&mut budget, &whole, usize::MAX), Some((1 << 20, false)));
assert_eq!(read(&mut budget, &whole, 4 << 20), Some((1 << 20, false)));
let unchecked = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() - 4).unwrap_or_default().to_vec());
assert_eq!(read(&mut budget, &unchecked, usize::MAX), Some((1 << 20, false)));
assert_eq!(read(&mut budget, &Stream::from_raw(Dict::new(), b"0 0 m".to_vec()), usize::MAX), Some((5, false)));
// A later stage's damage is the stream's.
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec());
assert_eq!(budget.decode_with_damage(&doc, &hex, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
// So is an earlier stage's, though the stage after it reads all it was given: a stored
// block of whole hex, then a block of the reserved type (RFC 1951 §3.2.3).
let readable = b"4142>";
let len = u16::try_from(readable.len()).unwrap_or_default();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
let flate_damaged = Stream::from_raw(hex.dict.clone(), raw);
assert_eq!(budget.decode_with_damage(&doc, &flate_damaged, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
}
/// `/DecodeParms` given by reference, or a parameter given by reference in them, is read: a
/// predicted stream decodes as with them direct. Of the stream, decoding copies no more than
/// its filters and their parameters.
#[test]
fn parameters_given_by_reference_are_read() {
let content = b"q 10 0 0 10 0 0 cm /Im Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let raw = Stream::flate(Dict::new(), &rows).raw;
let mut doc = Document::new_empty();
let direct: Dict = [(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect();
let by_reference = Object::Ref(doc.add(Object::Dict(direct.clone())));
let predictor = doc.add(Object::Int(12));
let inner = Object::Dict([(b"Predictor".to_vec(), Object::Ref(predictor))].into_iter().collect());
for parms in [Object::Dict(direct), by_reference, inner] {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), parms.clone());
dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000]));
let s = Stream::from_raw(dict, raw.to_vec());
assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}");
assert_eq!(DecodeBudget::page().decode_strict(&doc, &s).ok(), Some(content.to_vec()), "{parms:?}");
let r = doc.add(Object::Stream(s.clone()));
let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)]));
assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}");
assert_eq!(DecodeBudget::page().decode_contents(&doc, &Object::Ref(r)), [Ok(content.to_vec())], "{parms:?}");
let view = doc.decoding_view(&s);
assert!(matches!(view, std::borrow::Cow::Borrowed(_)) || view.dict.len() == 2, "{:?}", view.dict);
}
}
/// A page's content streams each say whether they are damaged, in their places.
#[test]
fn a_pages_streams_each_say_whether_they_are_damaged() {
let mut doc = Document::new_empty();
let whole = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 m 1 1 l S")));
let Object::Stream(s) = &*doc.get(whole) else { panic!("not a stream") };
let cut = Stream::from_raw(s.dict.clone(), s.raw.get(..s.raw.len() - 6).unwrap_or_default().to_vec());
let cut = doc.add(Object::Stream(cut));
let contents = Object::Array(vec![Object::Ref(whole), Object::Null, Object::Ref(cut)]);
let mut budget = DecodeBudget::page();
let read: Vec<_> = budget.decode_contents_with_damage(&doc, &contents).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(read, [Some(false), Some(true)]);
let one: Vec<_> =
budget.decode_contents_with_damage(&doc, &Object::Ref(cut)).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(one, [Some(true)]);
assert_eq!(budget.decode_contents(&doc, &contents).iter().filter(|r| r.is_ok()).count(), 2, "read up to the damage");
}
/// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it,
/// gives nothing back, and each reference still pays for what it decoded.
#[test]
@@ -832,7 +1022,7 @@ mod tests {
assert_eq!(budget.remaining(), (10 << 20) - charged);
assert!(budget.decode_within(&chained, 2 << 20).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 2 * charged);
assert!(budget.decode_strict(&chained).is_ok());
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
}
}
+4 -2
View File
@@ -24,11 +24,11 @@ pub(crate) use objects::Held;
pub(crate) use reader::find;
#[cfg(test)]
use reader::rfind;
pub(crate) use state::ObjectReader;
use state::{ObjStm, Slot, Unreadable};
pub(crate) use state::{ObjectReader, held_bytes};
pub use state::{ReadState, SourceIdentity};
#[cfg(test)]
use state::{SECTION_READS, held_bytes, objstm_member};
use state::{SECTION_READS, objstm_member};
use xref::XrefIndex;
pub use xref::{Revision, XrefEntry};
@@ -48,6 +48,8 @@ pub struct Document {
/// `fill_xref_gaps`.
located: Arc<BTreeMap<u32, (u64, u16)>>,
repair_log: Arc<Vec<String>>,
/// See [`Self::rebuilt_unlinked_update`].
unlinked_update: bool,
/// What loading each file object gave, a failure as well: a damaged object is otherwise
/// loaded again on every read, and a member of an object stream that can't be decoded
/// decodes the stream again (`Document::load`).
+40 -9
View File
@@ -90,6 +90,13 @@ impl Document {
&self.repair_log
}
/// Whether the file was rebuilt from its object headers because its last update has no `/Prev`
/// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The
/// renderer rebuilds such a file under the same conditions, so the two read the same objects.
pub fn rebuilt_unlinked_update(&self) -> bool {
self.unlinked_update
}
/// Objects of the file that could not be read, lowest number first, as lines such as
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
@@ -323,7 +330,29 @@ impl Document {
/// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a
/// large object copied once for each key and filter.
pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) };
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
}
/// [`Document::with_direct_parms`] for decoding alone: where that copies `s`, this makes a
/// stream of `s`'s data, shared, and a dictionary of its `/Filter` and the parameters made
/// direct, all its filters read. A stream named again and again, as a page's `/Contents` may
/// list one, isn't copied whole each time.
pub(crate) fn decoding_view<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut dict = Dict::new();
if let Some(f) = s.dict.get(b"Filter") {
dict.set(b"Filter".to_vec(), f.clone());
}
dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(Stream { dict, raw: s.raw.clone() })
}
/// The `/DecodeParms` [`Document::with_direct_parms`] gives `s`, or `None` when it keeps `s`.
fn direct_parms(&self, s: &Stream) -> Option<Object> {
let p = s.dict.get(b"DecodeParms")?;
let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt"));
let filters: Vec<bool> = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().map(crypt).collect(),
@@ -331,7 +360,7 @@ impl Document {
None => Vec::new(),
};
if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
return Cow::Borrowed(s);
return None;
}
let mut changed = matches!(p, Object::Ref(_));
// Its numbers and flags kept, resolved; anything else is no filter's parameters (null).
@@ -358,12 +387,7 @@ impl Document {
}
_ => one(p, &mut changed),
};
if !changed {
return Cow::Borrowed(s);
}
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
changed.then_some(made)
}
/// `o`, or the object it refers to, without copying either.
@@ -462,7 +486,14 @@ impl Document {
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? };
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) };
let Some(p) = self.fax_params(s) else {
// The last filter gives the image's rows: rows without the tags of the PNG predictor
// they declare are read as they are (`pdfcraft_filters::decode_image_tolerant_within`).
return match height {
Some(rows) => s.decoded_image_rows_spending(max, rows, work),
None => s.decoded_spending(max, work),
};
};
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
// rows past /Height aren't drawn.
let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
+101 -11
View File
@@ -21,6 +21,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -109,6 +110,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -134,6 +136,8 @@ impl Document {
}
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
// The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`).
let mut cut_off = false;
let ok = match parsed {
Ok((entries, trailer, revisions, oddities)) => {
doc.entries = Arc::new(entries);
@@ -147,17 +151,22 @@ impl Document {
doc.authenticate(&enc, &credentials)?;
authenticated = true;
}
doc.root_is_catalog()
let catalog = doc.root_is_catalog();
cut_off = catalog && doc.update_cuts_off_the_pages();
catalog && !cut_off
}
Err(e) => {
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
false
}
};
if cut_off {
log.push(UNLINKED_UPDATE.into());
doc.unlinked_update = true;
} else if !ok && !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
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)?;
}
// Header generations are plaintext, including in encrypted files. Inspect only the
@@ -182,6 +191,24 @@ impl Document {
Ok(doc)
}
/// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one
/// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages`
/// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require
/// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file
/// is rebuilt from its object headers as a damaged one is (the newest definition of each
/// object wins), and the repair log says why. (A file whose `/Root` itself is missing is
/// rebuilt anyway.)
fn update_cuts_off_the_pages(&self) -> bool {
if self.revisions.len() != 1 {
return false;
}
let Some(root) = self.root() else { return false };
let Ok(catalog) = self.try_get_reference(root) else { return false };
let Object::Dict(catalog) = &*catalog else { return false };
// The page tree first: the scan for an earlier `startxref` reads the whole file.
!catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data)
}
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
// Authentication can change how the same input is decoded.
self.source_identity = Arc::new(());
@@ -203,13 +230,24 @@ impl Document {
// Read here, while nothing is decrypted yet.
let members = self.encryption_members(enc);
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::Password(password) => match pdfcraft_crypt::SecurityHandler::open(params.clone(), &id0, *password) {
Ok(handler) => Arc::new(handler),
// No password, or the empty one: what only the embedded files need isn't asked.
Err(pdfcraft_crypt::CryptError::WrongPassword) => match self.without_password(&dict, params) {
Some(handler) if password.is_none_or(str::is_empty) => Arc::new(handler),
_ if password.is_none() => return Err(CosError::NeedsPassword),
_ => return Err(CosError::WrongPassword),
},
Err(other) => return Err(CosError::Security(other.to_string())),
},
// The same encryption as the document whose key this is: that key opens it. A handler
// without the key opens only what it would open by itself.
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => {
if !handler.has_key() && !crate::security::efopen_only(self, &dict) {
return Err(CosError::NeedsPassword);
}
Arc::clone(handler)
}
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
};
self.security = Some(handler);
@@ -222,6 +260,46 @@ impl Document {
Ok(())
}
/// The handler for a document opened without a password that only its embedded files need
/// (see [`crate::security::efopen_only`]). Not a repair: ISO 32000-2 §7.6.6 has such a
/// document open so; [`Document::embedded_files_locked`] tells it.
fn without_password(&self, dict: &Dict, params: pdfcraft_crypt::EncryptDict) -> Option<pdfcraft_crypt::SecurityHandler> {
crate::security::efopen_only(self, dict).then(|| pdfcraft_crypt::SecurityHandler::without_key(params)).flatten()
}
/// A document opened without the password its embedded files need takes either password
/// later: with the file key its embedded files decrypt, so what was read as stored is read
/// again. Refused while an edited stream holds data read as stored, which the key would
/// encrypt a second time.
fn unlock_embedded_files(&mut self, password: &str) -> Result<(), CosError> {
let Some(current) = self.security.as_deref() else { return Ok(()) };
if self.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if self.reads_as_stored(s))) {
return Err(CosError::Security("an embedded file was edited before the password was given: save or undo that edit first".into()));
}
let handler = pdfcraft_crypt::SecurityHandler::open(current.dict().clone(), &self.first_id(), Some(password)).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?;
self.security = Some(Arc::new(handler));
self.source_identity = Arc::new(());
self.cache = Arc::default();
self.objstms = Arc::default();
Ok(())
}
/// Whether the document opened without the password its embedded files need (`/AuthEvent
/// /EFOpen`): their data is held, and saved, as stored, and isn't to be read as the files.
pub fn embedded_files_locked(&self) -> bool {
self.security.as_deref().is_some_and(|h| !h.has_key())
}
/// Whether `s`, a stream of this document, holds encrypted data that reads as stored because the
/// document opened without the password its embedded files need (see
/// [`Document::embedded_files_locked`]). Such data isn't the file it encrypts.
pub fn reads_as_stored(&self, s: &crate::Stream) -> bool {
self.security.as_deref().is_some_and(|h| crate::security::stream_reads_as_stored(self, h, s))
}
/// The first file identifier, part of the encryption key (empty when there is none).
fn first_id(&self) -> Vec<u8> {
let id = self.trailer.get(b"ID").map(|id| self.resolve(id));
@@ -268,6 +346,9 @@ impl Document {
/// 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.has_key() {
return self.unlock_embedded_files(password);
}
if current.auth() == pdfcraft_crypt::Auth::Owner {
return Ok(());
}
@@ -411,3 +492,12 @@ impl Document {
}
}
}
/// The repair-log line for a file rebuilt because its last update has no `/Prev`.
const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers";
/// Whether `data` has more than one `startxref`: an update was appended to it, or it is
/// linearized (whose first-page section links to the main one).
fn earlier_sections(data: &[u8]) -> bool {
find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some()
}
+39 -3
View File
@@ -635,10 +635,20 @@ impl Document {
// 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) {
let headers = object_headers(&data);
// Where `/XRef` occurs in the file, found in one pass.
let mut xref_marks = Vec::new();
let mut mark_from = 0;
while let Some(p) = find(&data, b"/XRef", mark_from, data.len()) {
xref_marks.push(p);
mark_from = p + 1;
}
for &(num, offset, generation, body) in &headers {
entries.insert(num, XrefEntry::InFile { offset, generation });
objstms.push(num);
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
// `/XRef` within the 1024 bytes after the header (the first one that is there).
let first = xref_marks.partition_point(|&p| p < body);
if xref_marks.get(first).is_some_and(|&p| p + 5 <= body.saturating_add(1024).min(data.len()))
&& let Ok(at) = usize::try_from(offset)
{
xref_streams.push(at);
@@ -827,7 +837,33 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<
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)
let hay = &hay[from..to];
// Look for the needle's rarest byte first, a plain byte scan, and compare the rest only there.
const COMMON: &[u8] = b" etaoinsrhldcumfpgwybvkxjqz";
let (k, rare) =
needle.iter().copied().enumerate().max_by_key(|&(_, b)| COMMON.iter().position(|&c| c == b).unwrap_or(COMMON.len())).unwrap_or((0, 0));
let mut i = k;
while let Some(p) = hay.get(i..).and_then(|rest| position_of(rest, rare)) {
let start = i + p - k;
if hay.get(start..start + needle.len()) == Some(needle) {
return Some(start + from);
}
i += p + 1;
}
None
}
/// The first position of `byte` in `hay`. Whole 32-byte blocks are tested without branching, which
/// the compiler turns into vector compares; only a block that holds the byte is searched.
fn position_of(hay: &[u8], byte: u8) -> Option<usize> {
const BLOCK: usize = 32;
let (blocks, rest) = hay.as_chunks::<BLOCK>();
for (i, block) in blocks.iter().enumerate() {
if block.iter().fold(false, |any, &b| any | (b == byte)) {
return block.iter().position(|&b| b == byte).map(|p| i * BLOCK + p);
}
}
rest.iter().position(|&b| b == byte).map(|p| blocks.len() * BLOCK + p)
}
pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
+1 -1
View File
@@ -335,7 +335,7 @@ const OWNED_BYTES_DEPTH: usize = 256;
/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of
/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB
/// (#5873).
pub(super) fn held_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
pub(crate) fn held_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
if depth > OWNED_BYTES_DEPTH {
return usize::MAX;
}
+93
View File
@@ -276,6 +276,10 @@ fn fax_images_decode_with_their_parameters() {
let mut understated = parms.clone();
understated.set(b"Rows".to_vec(), Object::Int(1));
assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
// More /Rows than /Height (PDFBOX-771: 1,084,975,104), as the renderer reads it too.
let mut overstated = parms.clone();
overstated.set(b"Rows".to_vec(), Object::Int(1_000_000_000));
assert_eq!(image(&doc, Some(Object::Dict(overstated)), 10, 3).unwrap(), bits, "/Height rows, not /Rows past it");
// The parameters as the file gives them, /Rows included, and Table 11's defaults.
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
@@ -2189,3 +2193,92 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
}
/// The width (`/MediaBox` x1) of each of `pages`, in order.
fn page_widths(pages: &[Dict]) -> Vec<Option<i64>> {
pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect()
}
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
let catalog = doc.get(doc.root().expect("root"));
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
}
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
/// the repair log says why; a save rewrites it in full, with every page.
#[test]
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
let pages = page_tree_pages(&reopened);
assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order");
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}");
let catalog = reopened.get(reopened.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved");
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
// The same file with `/Prev` reads through its chain, as before.
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
assert_eq!(linked.revisions().len(), 2);
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
}
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_too() {
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
assert_eq!(linked.revisions().len(), 2);
}
#[test]
fn find_agrees_with_a_plain_window_search() {
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
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)
};
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let alphabet = b"/XRef t\n<>0";
for _ in 0..20_000 {
let hay: Vec<u8> = (0..next() % 200).map(|_| alphabet[(next() % alphabet.len() as u64) as usize]).collect();
let needles: [&[u8]; 5] = [b"/XRef", b"trailer", b"R", b"ff", b"<<"];
let needle = needles[(next() % 5) as usize];
let from = (next() % (hay.len() as u64 + 1)) as usize;
let to = from + (next() % (hay.len() as u64 + 1 - from as u64)) as usize;
assert_eq!(find(&hay, needle, from, to), naive(&hay, needle, from, to), "{hay:?} {needle:?} {from} {to}");
}
}
+2 -2
View File
@@ -25,12 +25,12 @@ mod writer;
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};
pub use writer::{SaveOptions, pdf_date, reachable_objects, serialize, write_full, write_incremental};
pub use writer::{SaveOptions, civil_from_days, pdf_date, reachable_objects, serialize, write_full, write_incremental};
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CosError {
+130 -13
View File
@@ -57,19 +57,90 @@ impl PdfString {
/// non-ASCII bytes (written by some producers) is read as UTF-8 rather than as
/// PDFDocEncoding mojibake, and stray leading U+FEFF (doubled BOMs) are removed.
pub fn to_text(&self) -> String {
let s = match self.bytes.as_slice() {
self.text_chars().collect()
}
/// The characters [`PdfString::to_text`] decodes, one at a time, without building a string.
pub fn text_chars(&self) -> TextChars<'_> {
let decode = match self.bytes.as_slice() {
[0xFE, 0xFF, rest @ ..] => {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
String::from_utf16_lossy(&units)
let units: fn(&[u8; 2]) -> u16 = |c| u16::from_be_bytes(*c);
Decode::Utf16(char::decode_utf16(rest.as_chunks::<2>().0.iter().map(units)))
}
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xEF, 0xBB, 0xBF, rest @ ..] => Decode::Utf8Lossy { chunks: rest.utf8_chunks(), valid: "".chars(), replace: false },
b => match std::str::from_utf8(b) {
// Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch.
Ok(utf8) if !b.is_ascii() => utf8.to_owned(),
_ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(),
Ok(utf8) if !b.is_ascii() => Decode::Utf8(utf8.chars()),
_ => Decode::PdfDoc(b.iter()),
},
};
if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s }
TextChars { decode, leading: true }
}
/// Whether the string decodes to `s` ([`PdfString::to_text`]), compared without allocating.
pub fn text_eq(&self, s: &str) -> bool {
self.text_chars().eq(s.chars())
}
}
/// The characters of a text string: [`PdfString::text_chars`].
pub struct TextChars<'a> {
decode: Decode<'a>,
/// Still at the start, where stray U+FEFF (doubled BOMs) are dropped.
leading: bool,
}
/// The UTF-16BE code units after a BOM (an odd last byte is left out).
type Utf16Units<'a> = std::iter::Map<std::slice::Iter<'a, [u8; 2]>, fn(&[u8; 2]) -> u16>;
enum Decode<'a> {
Utf16(std::char::DecodeUtf16<Utf16Units<'a>>),
/// After a UTF-8 BOM: each invalid sequence is one U+FFFD, as `String::from_utf8_lossy` has it.
Utf8Lossy {
chunks: std::str::Utf8Chunks<'a>,
valid: std::str::Chars<'a>,
replace: bool,
},
Utf8(std::str::Chars<'a>),
PdfDoc(std::slice::Iter<'a, u8>),
}
impl Iterator for Decode<'_> {
type Item = char;
fn next(&mut self) -> Option<char> {
match self {
Decode::Utf16(units) => units.next().map(|u| u.unwrap_or(char::REPLACEMENT_CHARACTER)),
Decode::Utf8Lossy { chunks, valid, replace } => loop {
if let Some(c) = valid.next() {
return Some(c);
}
if *replace {
*replace = false;
return Some(char::REPLACEMENT_CHARACTER);
}
let chunk = chunks.next()?;
*valid = chunk.valid().chars();
*replace = !chunk.invalid().is_empty();
},
Decode::Utf8(chars) => chars.next(),
Decode::PdfDoc(bytes) => bytes.next().map(|&c| pdfcraft_crypt::pdfdoc_char(c)),
}
}
}
impl Iterator for TextChars<'_> {
type Item = char;
fn next(&mut self) -> Option<char> {
loop {
let c = self.decode.next()?;
if self.leading && c == '\u{FEFF}' {
continue;
}
self.leading = false;
return Some(c);
}
}
}
@@ -297,13 +368,18 @@ impl Stream {
/// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept
/// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter
/// stages together, and the result gives the bytes they decoded (the work done, on success
/// and on failure).
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
/// stages together, and the result gives whether the data stopped at damage (it is then what
/// decoded before it) and the bytes the stages decoded (the work done, on success and on
/// failure).
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
let mut damaged = false;
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent))
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, partial, spent)| {
damaged = partial;
(v, spent)
})
};
self.decode_chain(max, decode)
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
@@ -314,6 +390,14 @@ impl Stream {
pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter)
}
/// [`Stream::decoded_spending`] for the samples of an image `rows` high (its `/Height`):
/// [`pdfcraft_filters::decode_image_tolerant_within`].
pub(crate) fn decoded_image_rows_spending(&self, max: usize, rows: u32, work: &mut u64) -> Result<Vec<u8>, CosError> {
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
let chain = self.readable_filters().map_err(filter)?;
pdfcraft_filters::decode_image_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), rows, work).map_err(filter)
}
/// The stream decoded through its filters by `decode`, or copied when it has none, within
/// `max` and [`MAX_DECODED`].
fn decode_chain(
@@ -337,8 +421,14 @@ impl Stream {
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within(MAX_DECODED)
}
/// Like [`Stream::decoded_strict`], bounded by `max` (and [`MAX_DECODED`]): for data that
/// will be copied elsewhere, where a damaged stream must not pass for a shorter one.
pub fn decoded_strict_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max` and counted like
@@ -583,6 +673,33 @@ mod tests {
assert_eq!(PdfString::literal(Vec::new()).to_text(), "");
}
#[test]
fn text_eq_compares_what_to_text_decodes() {
let utf16 = |s: &str| [0xFE, 0xFF].into_iter().chain(s.encode_utf16().flat_map(u16::to_be_bytes)).collect::<Vec<u8>>();
// Each invalid UTF-8 sequence is one U+FFFD, as String::from_utf8_lossy has it.
let lossy = [0xEF, 0xBB, 0xBF, 0xC3, 0x28, b'x', 0xF0, 0x9F, 0x92, 0xFF];
assert_eq!(PdfString::literal(lossy.to_vec()).to_text(), String::from_utf8_lossy(&lossy[3..]));
let cases: Vec<Vec<u8>> = vec![
Vec::new(),
b"Plain".to_vec(),
vec![b'C', b'a', b'f', 0xE9, 0x18],
"目录".as_bytes().to_vec(),
utf16("第一章"),
vec![0xFE, 0xFF, 0xD8, 0x00, 0x00, b'A'],
vec![0xFE, 0xFF, 0xFE, 0xFF, 0x00, b'A', 0xFE, 0xFF],
lossy.to_vec(),
];
for bytes in cases {
let s = PdfString::literal(bytes);
let text = s.to_text();
assert!(s.text_eq(&text), "{text:?}");
assert!(!s.text_eq(&format!("{text}x")), "{text:?}");
assert!(text.is_empty() || !s.text_eq(&text[..text.len() - text.chars().last().map_or(0, char::len_utf8)]), "{text:?}");
}
// A U+FEFF after the first character is text and stays.
assert!(PdfString::literal(utf16("A\u{FEFF}")).text_eq("A\u{FEFF}"));
}
#[test]
fn pdfdoc_encoding_high_range() {
assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A");
+29 -6
View File
@@ -502,14 +502,19 @@ fn parse_indirect_in(
let mut after = Lexer::new(data, end);
after.eat_keyword(b"endstream")
};
// A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt.
let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len()));
let len = match declared {
Some(l) if valid(l) => l as usize,
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
_ => {
find_endstream(data, start, endstreams)
.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?
- start
}
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none
// left in the file, a /Length that fits is the stream's extent, "how many bytes of the
// PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers
// take it: a producer that wrote `endstrea` after every stream lost every font and
// every page's content.
_ => match find_endstream(data, start, endstreams) {
Some(end) => end - start,
None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?,
},
};
let range = start..start + len;
let raw = match shared {
@@ -660,6 +665,24 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
/// With no `endstream` left in the file, as a producer that writes `endstrea` leaves every
/// stream, a stream is the bytes its /Length gives, "how many bytes of the PDF file are used
/// for the stream's data" (ISO 32000-2 §7.3.8.2): it was an error, and the object null, so
/// such a file lost every font and page content. A /Length past the file, or none, is still
/// no stream.
#[test]
fn indirect_stream_without_endstream_is_its_length() {
for end in [&b"\r\nendstrea\rendobj\r\n"[..], b"\nendobj\n", b""] {
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO".to_vec();
data.extend_from_slice(end);
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{end:?}");
}
assert!(parse_indirect(b"7 0 obj\n<< /Length 6 >>\nstream\nHELLO", 0, &|_| None).is_err(), "past the file");
assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length");
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
+40
View File
@@ -128,6 +128,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
}
}
/// Whether `d`, an `/Encrypt` dictionary, has crypt filters that encrypt (`/CFM` `V2`, `AESV2` or
/// `AESV3`), each authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO
/// 32000-2 §7.6.6, Table 25): what a document asks for its embedded files alone, which it opens
/// without (§7.6.6: "EFOpen Authorization shall be required when accessing embedded files"). The
/// files use them through `/EFF` or through a `/Crypt` filter of their own. Its strings and streams
/// have to be in the clear too, which [`SecurityHandler::without_key`] checks.
pub(crate) fn efopen_only(doc: &Document, d: &Dict) -> bool {
let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec));
let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) else { return false };
let mut encrypting = Vec::new();
for (k, v) in cf.iter() {
let Some(f) = doc.resolve(v).as_dict().cloned() else { continue };
if !matches!(name(&f, b"CFM").as_deref(), Some(b"V2" | b"AESV2" | b"AESV3")) {
continue;
}
if name(&f, b"AuthEvent").as_deref() != Some(b"EFOpen") {
return false;
}
encrypting.push(k.clone());
}
!encrypting.is_empty()
}
/// Whether `s`, read from a document whose handler has no file key, holds data that handler would
/// decrypt, which therefore reads as stored: still encrypted.
pub(crate) fn stream_reads_as_stored(doc: &Document, h: &SecurityHandler, s: &Stream) -> bool {
if h.has_key() {
return false;
}
let _scope = FollowScope::enter();
let named = named_crypt_filter(doc, &s.dict);
let ty = s.dict.get(b"Type").map(|t| followed(doc, t));
let kind = match (&named, ty.as_deref().and_then(Object::as_name)) {
(Some(n), _) => StreamKind::Named(n),
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
_ => StreamKind::Normal,
};
h.decrypts(kind)
}
/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`.
fn is_crypt(doc: &Document, f: &Object) -> bool {
followed(doc, f).as_name() == Some(b"Crypt")
+169 -28
View File
@@ -15,7 +15,7 @@ use std::io::Write;
use std::sync::Arc;
use crate::CosError;
use crate::document::{Document, ObjectReader, XrefEntry};
use crate::document::{Document, ObjectReader, XrefEntry, held_bytes};
use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
/// Serialize one object. Deterministic: same object → same bytes.
@@ -23,9 +23,7 @@ pub fn serialize(o: &Object, out: &mut Vec<u8>) {
match o {
Object::Null => out.extend_from_slice(b"null"),
Object::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
Object::Int(i) => {
let _ = write!(out, "{i}");
}
Object::Int(i) => write_int(*i, out),
Object::Real(r) => write_real(*r, out),
Object::String(s) => write_string(s, out),
Object::Name(n) => write_name(n, out),
@@ -42,11 +40,46 @@ pub fn serialize(o: &Object, out: &mut Vec<u8>) {
Object::Dict(d) => write_dict(d, out),
Object::Stream(s) => write_stream(s, out),
Object::Ref(r) => {
let _ = write!(out, "{} {} R", r.num, r.generation);
write_digits(u64::from(r.num), out);
out.push(b' ');
write_digits(u64::from(r.generation), out);
out.extend_from_slice(b" R");
}
}
}
/// Upper-case hexadecimal digits.
const HEX: &[u8; 16] = b"0123456789ABCDEF";
/// The decimal digits of `v`, the same bytes `write!(out, "{v}")` gives, without the formatter.
fn write_digits(mut v: u64, out: &mut Vec<u8>) {
// `u64::MAX` has 20 digits, so `at` never goes below 0.
let mut buf = [0u8; 20];
let mut at = buf.len();
loop {
at -= 1;
buf[at] = b'0' + (v % 10) as u8;
v /= 10;
if v == 0 {
break;
}
}
out.extend_from_slice(&buf[at..]);
}
/// A signed integer in decimal, as `write!(out, "{i}")` writes it.
fn write_int(i: i64, out: &mut Vec<u8>) {
if i < 0 {
out.push(b'-');
}
write_digits(i.unsigned_abs(), out);
}
/// `b` as two upper-case hexadecimal digits.
fn hex_pair(b: u8) -> [u8; 2] {
[HEX[usize::from(b >> 4)], HEX[usize::from(b & 15)]]
}
fn write_real(r: f64, out: &mut Vec<u8>) {
if !r.is_finite() {
out.push(b'0');
@@ -55,7 +88,7 @@ fn write_real(r: f64, out: &mut Vec<u8>) {
// A whole number is written as an integer only within the integer range Annex C gives
// (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers.
if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) {
let _ = write!(out, "{}", r as i32);
write_int(i64::from(r as i32), out);
return;
}
// Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the
@@ -76,7 +109,8 @@ fn write_name(n: &[u8], out: &mut Vec<u8>) {
if regular {
out.push(b);
} else {
let _ = write!(out, "#{b:02X}");
out.push(b'#');
out.extend_from_slice(&hex_pair(b));
}
}
}
@@ -84,9 +118,10 @@ fn write_name(n: &[u8], out: &mut Vec<u8>) {
fn write_string(s: &PdfString, out: &mut Vec<u8>) {
let binary = s.bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count();
if s.hex || binary * 4 > s.bytes.len().max(1) {
out.reserve(s.bytes.len().saturating_mul(2).saturating_add(2));
out.push(b'<');
for b in &s.bytes {
let _ = write!(out, "{b:02X}");
for &b in &s.bytes {
out.extend_from_slice(&hex_pair(b));
}
out.push(b'>');
return;
@@ -105,7 +140,7 @@ fn write_string(s: &PdfString, out: &mut Vec<u8>) {
0x0C => out.extend_from_slice(b"\\f"),
b' '..=b'~' => out.push(b),
_ => {
let _ = write!(out, "\\{b:03o}");
out.extend_from_slice(&[b'\\', b'0' + (b >> 6), b'0' + ((b >> 3) & 7), b'0' + (b & 7)]);
}
}
}
@@ -131,6 +166,24 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
out.extend_from_slice(b"\nendstream");
}
/// A document opened without the password its embedded files need holds their data as stored and
/// can't encrypt: a save keeps its `/Encrypt` as it is, and writes no edited or added stream that
/// would have to be encrypted (an attached file put in, say), which would be written in the clear.
fn keyless_check(doc: &Document) -> Result<(), CosError> {
if !doc.embedded_files_locked() {
return Ok(());
}
let refuse =
|what: &str| Err(CosError::Security(format!("{what} without the password of the document's embedded files: open it with the password")));
if doc.encryption_changed() || doc.trailer().get(b"Encrypt").is_none() {
return refuse("the document's security can't change");
}
if doc.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if doc.reads_as_stored(s))) {
return refuse("an edited or added embedded file can't be encrypted");
}
Ok(())
}
/// The object as written: encrypted with its (output) number when the document is encrypted,
/// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document).
/// `o` has the document's numbers in its references ([`crate::security::transform`]).
@@ -207,6 +260,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
if doc.stream_limited() {
return Err(CosError::ReadOnlyLimit);
}
keyless_check(doc)?;
// Reconstructed files have no chain to append to; added or removed encryption must
// rewrite every object; redaction must not leave the old revision behind. All need a full save.
if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() {
@@ -275,14 +329,22 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
/// The objects [`write_full`] writes: everything reachable from the trailer's /Root, /Info and
/// /Encrypt and from its unknown entries, breadth-first, with the new number each gets
/// (aliases of one slot included) and, in visit order, whether it is a stream.
type SaveOrder = (HashMap<ObjRef, u32>, Vec<(ObjRef, bool)>);
/// (aliases of one slot included) and, in visit order, whether it is a stream and the parsed
/// object where the budget allowed keeping it.
type SaveOrder = (HashMap<ObjRef, u32>, Vec<(ObjRef, bool)>, Vec<Option<Arc<Object>>>);
fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict) -> Result<SaveOrder, CosError> {
/// Number the objects reachable from the trailer. `retain` keeps parsed objects for the write
/// pass, up to the budget. A caller that needs only the numbers passes `false`, so the traversal
/// holds no parsed objects beyond the current one.
fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict, retain: bool) -> Result<SaveOrder, CosError> {
// Breadth-first walk from the trailer, assigning new numbers in visit order.
let mut map: HashMap<ObjRef, u32> = HashMap::new();
// Record the type while traversing so classifying packed objects needs no second parse.
let mut order: Vec<(ObjRef, bool)> = Vec::new();
// The parsed object of each entry in `order`, kept for the write pass while the budget
// allows it. Past the budget the write pass reads the object again.
let mut kept: Vec<Option<Arc<Object>>> = Vec::new();
let mut kept_budget = KeptObjects::default();
let mut queue: VecDeque<ObjRef> = VecDeque::new();
let visit_refs = |o: &Object, queue: &mut VecDeque<ObjRef>| collect_refs(o, &mut |r| queue.push_back(r));
for key in [&b"Root"[..], b"Info", b"Encrypt"] {
@@ -321,8 +383,9 @@ fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict) -> Result<SaveO
map.insert(canonical, num);
order.push((canonical, matches!(*o, Object::Stream(_))));
visit_refs(&o, &mut queue);
kept.push((retain && kept_budget.admit(&o, reader.document.bytes())).then_some(o));
}
Ok((map, order))
Ok((map, order, kept))
}
/// The object numbers a full save of `doc` would write: exactly the objects [`write_full`]
@@ -331,7 +394,7 @@ fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict) -> Result<SaveO
pub fn reachable_objects(doc: &Document) -> Result<HashSet<u32>, CosError> {
let mut reader = doc.object_reader();
let trailer_in = reader.document.trailer().clone();
let (_, order) = full_save_order(&mut reader, &trailer_in)?;
let (_, order, _) = full_save_order(&mut reader, &trailer_in, false)?;
Ok(order.into_iter().map(|(r, _)| r.num).collect())
}
@@ -340,12 +403,20 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
if doc.stream_limited() {
return Err(CosError::ReadOnlyLimit);
}
keyless_check(doc)?;
// Renumbering breaks a key that mixes in the object number.
if doc.security().is_some_and(|h| !h.has_key() && !h.survives_renumbering()) {
return Err(CosError::Security(
"the embedded files are encrypted and the document was opened without their password: open it with the password to save it in full"
.into(),
));
}
// A full traversal must not fill the editor's shared caches (which undo snapshots also
// own). Parsed objects and decoded object streams are temporary working data here.
let mut reader = doc.object_reader();
stamp_mod_date(&mut reader.document, opts);
let trailer_in = reader.document.trailer().clone();
let (map, order) = full_save_order(&mut reader, &trailer_in)?;
let (map, order, mut kept) = full_save_order(&mut reader, &trailer_in, true)?;
let version = reader.document.version().chars().take(3).collect::<String>();
let version = if opts.object_streams && version.as_str() < "1.5" { "1.5".to_string() } else { version };
let mut out = Vec::new();
@@ -375,14 +446,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
let mut rows: BTreeMap<u32, Row> = BTreeMap::new();
// Keep only references, not renamed copies of the entire object graph. Standalone
// objects still precede object streams, preserving the established output order.
let mut packed: Vec<(u32, ObjRef)> = Vec::new();
let mut packed: Vec<(u32, ObjRef, Option<Arc<Object>>)> = Vec::new();
for (i, (r, is_stream)) in order.iter().enumerate() {
let num = output_number(i)?;
let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(&num) && root_outside != Some(num);
if in_stream {
packed.push((num, *r));
packed.push((num, *r, kept[i].take()));
} else {
let o = reread(&mut reader, *r, *is_stream)?;
let o = written_object(&mut reader, *r, *is_stream, kept[i].take())?;
let o = renumber(&prepared(&reader.document, r.num, num, 0, &o), &map);
rows.insert(num, Row::InFile(out.len() as u64, 0));
write_indirect(num, 0, &o, &mut out);
@@ -392,9 +463,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
// individually: the whole stream is (§7.5.7, §7.6.2).
let mut next = output_number(order.len())?;
let mut batch = ObjectStreamBatch::default();
for (num, reference) in packed {
for (num, reference, parsed) in packed {
let mut encoded = Vec::new();
serialize(&renumber(&*reread(&mut reader, reference, false)?, &map), &mut encoded);
serialize(&renumber(&*written_object(&mut reader, reference, false, parsed)?, &map), &mut encoded);
if batch.count > 0 && batch.body.len().saturating_add(encoded.len()).saturating_add(1) > OBJSTM_BYTES {
batch.flush(&reader.document, &mut next, &mut rows, &mut out)?;
}
@@ -428,6 +499,38 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
Ok(out)
}
/// What `write_full` keeps of the objects it parsed while numbering them, measured with
/// [`held_bytes`] (the parsed tree and any data that is not a view into the file). Beyond the
/// budget objects are read again when written, which bounds the memory of a save of a huge
/// document.
#[derive(Default)]
struct KeptObjects {
bytes: usize,
}
impl KeptObjects {
const BYTES: usize = 64 * 1024 * 1024;
/// Whether `object` still fits the budget; if so it is counted against it.
fn admit(&mut self, object: &Object, data: &Arc<Vec<u8>>) -> bool {
let bytes = self.bytes.saturating_add(held_bytes(object, data, 0));
if bytes > Self::BYTES {
return false;
}
self.bytes = bytes;
true
}
}
/// The object to write for `r`: the one parsed while numbering when it was kept, else `r` read
/// again.
fn written_object(reader: &mut ObjectReader, r: ObjRef, is_stream: bool, kept: Option<Arc<Object>>) -> Result<Arc<Object>, CosError> {
match kept {
Some(o) => Ok(o),
None => reread(reader, r, is_stream),
}
}
/// Assemble the full rewrite's trailer and apply its identifier policy.
fn full_save_trailer(doc: &Document, trailer_in: &Dict, map: &HashMap<ObjRef, u32>, opts: &SaveOptions, out: &[u8]) -> Dict {
let mut trailer = Dict::new();
@@ -843,14 +946,14 @@ pub fn pdf_date(unix_secs: i64) -> String {
format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). Total for any `i64` count:
/// dates past `NaiveDate`'s ±262143-year range keep their proleptic Gregorian year instead of
/// falling back to the epoch.
///
/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range,
/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every
/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back
/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar
/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
/// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day
/// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't
/// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163.
pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
@@ -935,6 +1038,38 @@ mod tests {
Lexer::new(&out, 0).object().expect("parses")
}
#[test]
fn kept_objects_stop_at_their_byte_budget() {
let data = Arc::new(Vec::new());
let string = Object::String(PdfString::literal(vec![b'x'; 1000]));
let array = Object::Array(vec![Object::Int(1), Object::Null]);
let mut kept = KeptObjects::default();
assert!(kept.admit(&string, &data) && kept.admit(&array, &data));
assert_eq!(kept.bytes, held_bytes(&string, &data, 0) + held_bytes(&array, &data, 0));
let mut full = KeptObjects { bytes: KeptObjects::BYTES - held_bytes(&string, &data, 0) + 1 };
let before = full.bytes;
assert!(!full.admit(&string, &data), "one byte over the budget");
assert_eq!(full.bytes, before, "a refused object is not counted");
let mut deep = Object::Null;
for _ in 0..300 {
deep = Object::Array(vec![deep]);
}
assert!(!KeptObjects::default().admit(&deep, &data), "nesting past the limit is never kept");
}
#[test]
fn kept_objects_count_names_and_dictionary_keys() {
let data = Arc::new(Vec::new());
let name = Object::Name(vec![b'n'; KeptObjects::BYTES + 1]);
let mut kept = KeptObjects::default();
assert!(!kept.admit(&name, &data), "a name over the byte budget is not kept");
assert_eq!(kept.bytes, 0);
let mut dict = Dict::new();
dict.set(vec![b'k'; KeptObjects::BYTES + 1], Object::Null);
assert!(!kept.admit(&Object::Dict(dict), &data), "nor a dictionary key");
assert_eq!(kept.bytes, 0);
}
#[test]
fn a_second_read_that_differs_fails_the_save_instead_of_writing_null() {
let mut doc = Document::new_empty();
@@ -1002,6 +1137,12 @@ mod tests {
// its sign, exactly as before).
assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z");
assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z");
assert_eq!(crate::civil_from_days(0), (1970, 1, 1));
assert_eq!(crate::civil_from_days(-1), (1969, 12, 31));
assert_eq!(crate::civil_from_days(11_016), (2000, 2, 29));
assert_eq!(crate::civil_from_days(19_782), (2024, 2, 29));
assert_eq!(crate::civil_from_days(i64::MAX), (25_252_734_927_768_524, 7, 27));
assert_eq!(crate::civil_from_days(i64::MIN), (-25_252_734_927_764_585, 6, 7));
}
#[test]
+224
View File
@@ -0,0 +1,224 @@
//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent
//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored,
//! and its embedded files keep their encrypted bytes, flagged as such, through every save.
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_crypt::StreamKind;
const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
const ATTACHED: &[u8] = b"the attached file's own words";
const PASSWORD: &str = "attach pw";
const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 };
const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 };
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect()
}
/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is
/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`.
fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec<u8> {
fixture_with(algorithm, auth_event, false)
}
/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with
/// no `/EFF` (ISO 32000-2 §7.4.10).
fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec<u8> {
let id0 = [7u8; 16];
let params =
NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] };
let handler = pdfcraft_crypt::create(&params, &id0).unwrap();
let d = handler.dict();
let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile };
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind);
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let (version, cfm, r6) = match algorithm {
Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))),
_ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()),
};
let encrypt = format!(
"<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>",
hex(&d.o),
hex(&d.u),
d.p
);
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(),
b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(),
];
let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes();
stream.extend_from_slice(&cipher);
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
objs.push(encrypt.into_bytes());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
let trailer = format!(
"trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n",
objs.len() + 1,
hex(&id0),
hex(&id0)
);
out.extend_from_slice(trailer.as_bytes());
out
}
fn stream(doc: &Document, r: ObjRef) -> pdfcraft_cos::Stream {
match &*doc.get(r) {
Object::Stream(s) => s.clone(),
other => panic!("object {} isn't a stream: {other:?}", r.num),
}
}
/// The attachment as the password reads it from `bytes`.
fn attachment_with_password(bytes: Vec<u8>) -> Vec<u8> {
let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap();
assert!(!doc.embedded_files_locked());
let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
assert!(embedded.get(b"Names").is_some());
stream(&doc, EMBEDDED).decoded().unwrap()
}
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
// Not a repair: the specification has it open so.
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert!(doc.trailer().get(b"Encrypt").is_some());
// The page reads as stored; the attachment keeps its encrypted bytes and says so.
let content = stream(&doc, CONTENT);
assert_eq!(content.decoded().unwrap(), PAGE.as_bytes());
assert!(!doc.reads_as_stored(&content));
let embedded = stream(&doc, EMBEDDED);
assert!(doc.reads_as_stored(&embedded));
assert_ne!(embedded.decoded().unwrap(), ATTACHED);
// The password still opens it, and reads the attachment.
assert_eq!(attachment_with_password(bytes.clone()), ATTACHED);
// A wrong password is still refused.
assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword)));
}
#[test]
fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() {
let bytes = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword)));
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
#[test]
fn saves_keep_the_encrypted_embedded_files_as_stored() {
for algorithm in [Algorithm::Aes256, Algorithm::Aes128] {
let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap();
assert!(doc.embedded_files_locked(), "{algorithm:?}");
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap();
// An incremental save keeps /Encrypt, and the attachment decrypts with the password.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}");
// A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do.
match algorithm {
Algorithm::Aes256 => {
let full = write_full(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(full.clone())).unwrap();
assert!(reopened.embedded_files_locked());
let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap();
let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap();
let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap();
let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap();
let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap();
let file = match &*doc.resolve(ef.get(b"F").unwrap()) {
Object::Stream(s) => s.decoded().unwrap(),
other => panic!("{other:?}"),
};
assert_eq!(file, ATTACHED);
}
_ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"),
}
}
}
#[test]
fn security_cannot_change_without_the_embedded_files_password() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
doc.remove_encryption();
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a
/// document opened without the key open no other.
#[test]
fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked());
// The same /Encrypt values and /ID, authorised when the document opens.
let doc_open = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword)));
assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword)));
}
/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way.
#[test]
fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() {
let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true);
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED)));
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an
/// embedded file, is refused rather than written in the clear.
#[test]
fn saves_refuse_what_would_need_the_key() {
let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let mut doc = open();
doc.trailer_mut().remove(b"Encrypt");
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
let mut doc = open();
let mut replaced = stream(&doc, EMBEDDED);
replaced.raw = b"new words".to_vec().into();
doc.set(EMBEDDED, Object::Stream(replaced));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// The password given after opening unlocks the embedded files; a wrong one doesn't.
#[test]
fn the_password_given_later_unlocks_the_embedded_files() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword)));
assert!(doc.embedded_files_locked());
doc.unlock(PASSWORD).unwrap();
assert!(!doc.embedded_files_locked());
assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED);
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
// Not while an embedded file read as stored has been edited.
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let edited = stream(&doc, EMBEDDED);
doc.set(EMBEDDED, Object::Stream(edited));
assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_))));
}
+2
View File
@@ -2,8 +2,10 @@
mod bounded_writer;
mod corpus;
mod efopen;
mod encryption;
mod generations;
mod proptests;
mod serialize_format;
mod shared_streams;
mod writer;
+116
View File
@@ -0,0 +1,116 @@
//! `serialize` writes integers, references, hexadecimal strings, octal escapes and `#xx` name
//! escapes with its own digit loops. They must give the bytes the standard formatter gives.
use pdfcraft_cos::{ObjRef, Object, PdfString, serialize};
use proptest::prelude::*;
fn bytes_of(o: &Object) -> Vec<u8> {
let mut v = Vec::new();
serialize(o, &mut v);
v
}
#[test]
fn integer_extremes_and_digit_boundaries() {
let mut cases = vec![0i64, 1, -1, 9, 10, 99, 100, i64::MAX, i64::MIN, i64::MIN + 1, i64::from(i32::MAX), i64::from(i32::MIN)];
let mut p = 1i64;
while let Some(next) = p.checked_mul(10) {
cases.extend([next - 1, next, next + 1, -next]);
p = next;
}
for i in cases {
assert_eq!(bytes_of(&Object::Int(i)), i.to_string().into_bytes(), "{i}");
}
}
#[test]
fn reference_extremes() {
for (n, g) in [(0, 0), (1, 0), (u32::MAX, u16::MAX), (10, 65535), (4_000_000_000, 7)] {
assert_eq!(bytes_of(&Object::Ref(ObjRef::new(n, g))), format!("{n} {g} R").into_bytes());
}
}
#[test]
fn every_byte_value_in_hex_strings_octal_escapes_and_names() {
let all: Vec<u8> = (0..=255).collect();
let hex: String = all.iter().map(|b| format!("{b:02X}")).collect();
assert_eq!(bytes_of(&Object::String(PdfString { bytes: all.clone(), hex: true })), format!("<{hex}>").into_bytes());
// A literal string with a few control bytes escapes them in octal.
let lit = bytes_of(&Object::String(PdfString { bytes: b"abcdefghijkl\x01\x7f\xffz".to_vec(), hex: false }));
assert_eq!(lit, b"(abcdefghijkl\\001\\177\\377z)");
let name = bytes_of(&Object::Name(all.clone()));
let mut want = b"/".to_vec();
for &b in &all {
let regular = (b'!'..=b'~').contains(&b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' | b'#');
if regular {
want.push(b);
} else {
want.extend_from_slice(format!("#{b:02X}").as_bytes());
}
}
assert_eq!(name, want);
}
#[test]
fn whole_real_at_i32_min_stays_a_real_token() {
// -2^31 is outside the Annex C integer range (±2^31-1), so it keeps its trailing point.
assert_eq!(bytes_of(&Object::Real(f64::from(i32::MIN))), b"-2147483648.");
}
proptest! {
#[test]
fn integers_match_the_formatter(i in any::<i64>()) {
prop_assert_eq!(bytes_of(&Object::Int(i)), format!("{i}").into_bytes());
}
#[test]
fn whole_reals_match_the_formatter(i in -i32::MAX..=i32::MAX) {
prop_assert_eq!(bytes_of(&Object::Real(f64::from(i))), format!("{i}").into_bytes());
}
#[test]
fn references_match_the_formatter(n in any::<u32>(), g in any::<u16>()) {
prop_assert_eq!(bytes_of(&Object::Ref(ObjRef::new(n, g))), format!("{n} {g} R").into_bytes());
}
#[test]
fn hex_strings_match_the_formatter(bytes in proptest::collection::vec(any::<u8>(), 0..300)) {
let mut want = String::from("<");
for b in &bytes {
want.push_str(&format!("{b:02X}"));
}
want.push('>');
prop_assert_eq!(bytes_of(&Object::String(PdfString { bytes, hex: true })), want.into_bytes());
}
#[test]
fn binary_literal_strings_match_the_formatter(bytes in proptest::collection::vec(any::<u8>(), 0..300)) {
// Mostly-binary input is written as hex; mostly-text input with escapes is a literal.
let got = bytes_of(&Object::String(PdfString { bytes: bytes.clone(), hex: false }));
let binary = bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count();
let mut want = Vec::new();
if binary * 4 > bytes.len().max(1) {
want.push(b'<');
for b in &bytes {
want.extend_from_slice(format!("{b:02X}").as_bytes());
}
want.push(b'>');
} else {
want.push(b'(');
for &b in &bytes {
match b {
b'(' | b')' | b'\\' => want.extend_from_slice(&[b'\\', b]),
b'\n' => want.extend_from_slice(b"\\n"),
b'\r' => want.extend_from_slice(b"\\r"),
b'\t' => want.extend_from_slice(b"\\t"),
0x08 => want.extend_from_slice(b"\\b"),
0x0C => want.extend_from_slice(b"\\f"),
b' '..=b'~' => want.push(b),
_ => want.extend_from_slice(format!("\\{b:03o}").as_bytes()),
}
}
want.push(b')');
}
prop_assert_eq!(got, want);
}
}
+45 -10
View File
@@ -183,12 +183,17 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
// a negative.
let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk));
// a negative. The space is read once: an indexed one's lookup table is paid for each time.
let read = (jpeg && !inverted).then(|| space(doc, s.dict.get(b"ColorSpace"), budget));
let as_is_negative = matches!(read, Some(Ok(Space::Cmyk)));
if jpeg && alpha.is_none() && key.is_none() && !as_is_negative {
return as_is();
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let mut space = match read {
_ if mask => Space::Gray,
Some(read) => read?,
None => space(doc, s.dict.get(b"ColorSpace"), budget)?,
};
let (samples, bpc) = if jpeg {
if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) {
return Err(FilterError::Spent.to_string());
@@ -297,9 +302,14 @@ thread_local! {
}
/// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary
/// says: an image of one pixel can hold a header of 500 megapixels.
/// says: an image of one pixel can hold a header of 500 megapixels. `None`, as for a JPEG that
/// can't be read, when its metadata would take the decoder quadratic time
/// ([`decodable_metadata`](crate::upright_image::decodable_metadata)).
fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> {
use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions};
if !crate::upright_image::decodable_metadata(data) {
return None;
}
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
@@ -324,6 +334,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),
_ => {}
}
// Past the decoder's metadata bound, drop the Extended XMP chunks rather than the mask:
// decoded as it stands, an over-chunked JPEG is written as is, without its /SMask.
let stripped;
let data = if crate::upright_image::decodable_metadata(data) {
data
} else {
stripped = crate::upright_image::without_extended_xmp(data);
&stripped[..]
};
let img = jpeg_of_size(data, w, h)?;
Some(match space {
Space::Gray => (img.into_luma8().into_raw(), Space::Gray),
@@ -335,6 +354,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
/// a decoder converting to RGB inverts them, with YCCK converted to CMYK.
fn cmyk_jpeg_samples(data: &[u8], w: usize, h: usize) -> Option<Vec<u8>> {
use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
// Past the decoder's metadata bound, decode the image without its Extended XMP chunks
// rather than not at all: written out as stored, a CMYK JPEG shows inverted colours.
let stripped;
let data = if crate::upright_image::decodable_metadata(data) {
data
} else {
stripped = crate::upright_image::without_extended_xmp(data);
&stripped[..]
};
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
@@ -436,7 +464,10 @@ impl Space {
/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that.
const MAX_LOOKUP: usize = 64 << 10;
fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
/// The colour space `cs` names; an indexed one's lookup table is read within `budget`, as the
/// image's samples are, since each image reads it again and its filters can do work that makes
/// nothing.
fn space(doc: &Document, cs: Option<&Object>, budget: &mut ImageBudget) -> Result<Space, String> {
let Some(cs) = cs else { return Err("the image has no colour space".into()) };
let cs = doc.resolve(cs);
let unsupported = |n: &[u8]| Err(format!("{} images can't be exported yet", String::from_utf8_lossy(n)));
@@ -470,7 +501,7 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
if nested {
return Err("nested indexed colour spaces aren't valid".into());
}
let base = space(doc, a.get(1))?;
let base = space(doc, a.get(1), budget)?;
let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() {
Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8,
_ => return Err("an indexed colour space with an invalid hival".into()),
@@ -478,10 +509,14 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
// Through its parameters given by reference too, as the image's samples.
Some(Object::Stream(t)) => doc
.with_direct_parms(t)
.decoded_within(MAX_LOOKUP)
.map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?,
Some(Object::Stream(t)) => {
let refused = budget.refused();
match budget.decode_filters(doc, t, MAX_LOOKUP) {
Ok(table) => table,
Err(_) if budget.refused() > refused => return Err(FilterError::Spent.to_string()),
Err(_) => return Err("an indexed colour space with an oversized or damaged lookup table".into()),
}
}
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
+156
View File
@@ -941,6 +941,43 @@ fn damaged_masks_and_sizes_never_panic() {
}
}
#[test]
fn a_jpeg_with_thousands_of_extended_xmp_chunks_keeps_its_mask() {
// Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha. A JPEG
// whose Extended XMP chunks would take the decoder quadratic time is decoded without them,
// which is linear in what is left, so the mask survives; a JPEG mask past the same bound is
// still left out, without reaching the decoder.
let flood = crate::upright_image::tests::with_extended_xmp_chunks(&real_jpeg(), 5_000);
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0; 8])));
let mut d = image_dict(4, 2, "DeviceGray", 8);
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
d.set(b"SMask".to_vec(), Object::Ref(mask));
Stream::from_raw(d, flood.clone())
});
crate::extract::JPEGS_DECODED.with(|n| n.set(0));
let out = extract_images(&doc, &[0], 0);
assert_eq!(out.images[0].extension, "png");
assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 1);
// The chunks themselves are gone, so the decoder never saw them.
assert!(!out.images[0].data.windows(5).any(|w| w == b"http:"));
crate::extract::JPEGS_DECODED.with(|n| n.set(0));
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut md = image_dict(4, 2, "DeviceGray", 8);
md.set(b"Filter".to_vec(), Object::name("DCTDecode"));
let mask = doc.add(Object::Stream(Stream::from_raw(md, flood.clone())));
let mut d = image_dict(4, 2, "DeviceRGB", 8);
d.set(b"SMask".to_vec(), Object::Ref(mask));
Stream::from_raw(d, vec![10; 24])
});
assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]);
assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 0);
}
#[test]
fn indexed_image_export_clamps_samples_to_hival() {
for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] {
@@ -2409,6 +2446,98 @@ fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() {
assert_eq!(budget.refused(), 1);
}
/// An indexed image's lookup table, given as a stream, is read within the export's allowance as
/// the image's samples are: each image reads it again, and its filters can do work that makes
/// nothing (Flate over empty blocks). Past the allowance the image is left out with the reason.
#[test]
fn a_lookup_table_is_read_within_the_exports_allowance() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, PdfString, Stream};
let table = [255, 0, 0, 0, 0, 255];
let lookup = Stream::flate(Dict::new(), &table);
let exported = |as_stream: bool, work: u64| {
let mut doc = Document::new_empty();
let table = if as_stream { Object::Ref(doc.add(Object::Stream(lookup.clone()))) } else { Object::String(PdfString::literal(table.to_vec())) };
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(2));
dict.set(b"Height".to_vec(), Object::Int(1));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), table]));
let image = doc.add(Object::Stream(Stream::flate(dict, &[0, 1])));
let mut budget = ImageBudget::with_work(work);
let file = crate::extract::image_file_within(&doc, image, &mut budget);
(file, work - budget.work_left(), budget.refused())
};
let (direct, image_alone, _) = exported(false, u64::MAX);
let (streamed, both, _) = exported(true, u64::MAX);
assert_eq!(streamed.unwrap(), direct.unwrap(), "the same picture");
let read = (lookup.raw.len() + table.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * lookup.raw.len() as u64;
assert_eq!(both, image_alone + read, "the table is paid for as it is read");
let (refused, _, count) = exported(true, image_alone);
assert!(refused.unwrap_err().contains("more image decoding than"));
assert_eq!(count, 1);
}
/// A JPEG's colour space is read once for an export, its lookup table paid for once: whether it is
/// CMYK (a negative as is) and what its samples are both come from that reading. An indexed JPEG
/// isn't valid, and is written as it is, but its soft mask makes it go on past the first. With the
/// mask, a table the allowance can't pay for leaves the image out, with the reason.
#[test]
fn a_jpegs_lookup_table_is_paid_for_once() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, PdfString, Stream};
let table = [255, 0, 0, 0, 0, 255];
let lookup = Stream::flate(Dict::new(), &table);
let mut jpeg = Vec::new();
image::GrayImage::from_pixel(2, 1, image::Luma([1])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
// `given`: None leaves the colour space out, Some(true) gives the table as a stream.
let exported = |given: Option<bool>, work: u64| {
let mut doc = Document::new_empty();
let table = given.map(|as_stream| {
if as_stream { Object::Ref(doc.add(Object::Stream(lookup.clone()))) } else { Object::String(PdfString::literal(table.to_vec())) }
});
let mut mask = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
mask.set(k.as_bytes().to_vec(), v);
}
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let mask = doc.add(Object::Stream(Stream::flate(mask, &[255, 128])));
let mut dict = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
dict.set(k.as_bytes().to_vec(), v);
}
if let Some(table) = table {
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), table]));
}
dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));
dict.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(dict, jpeg.clone())));
let mut budget = ImageBudget::with_work(work);
let file = crate::extract::image_file_within(&doc, image, &mut budget);
(file, work - budget.work_left(), budget.refused())
};
let (direct, direct_cost, _) = exported(Some(false), u64::MAX);
let (streamed, streamed_cost, _) = exported(Some(true), u64::MAX);
assert_eq!(direct, Ok(("jpg", jpeg.clone())), "written as it is");
assert_eq!(streamed, direct);
let read = (lookup.raw.len() + table.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * lookup.raw.len() as u64;
assert_eq!(streamed_cost, direct_cost + read, "one reading of the table");
// Without a colour space nothing is read after the soft mask, so this is what the mask costs.
let (none, mask, _) = exported(None, u64::MAX);
assert_eq!(none.unwrap_err(), "the image has no colour space");
// Read first, the table would be refused a step short of the mask too, and the mask after it.
assert!(read >= mask, "the table costs at least what the mask does");
assert_eq!(exported(Some(true), mask - 1).2, 1, "the mask is read first, and refused alone");
let (refused, _, count) = exported(Some(true), mask + read - 1);
let refused = refused.unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!(count, 1, "refused at the table, and nothing after it asks to be paid for");
}
/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that
/// pays for its samples, and past the allowance the image goes, with the reason, as it would come out without
/// its transparency otherwise.
@@ -2696,3 +2825,30 @@ fn proportions_up_to_the_limit_scale_as_they_did() {
let mask = noise(20, 10);
assert_eq!(crate::extract::scaled(&mask, 20, 10), mask);
}
/// A CMYK JPEG past the decoder's Extended XMP bound is still decoded (its chunks left out) and
/// exported with the colours the page shows, not written as stored, which inverts them.
#[test]
fn a_cmyk_jpeg_flooded_with_extended_xmp_is_exported_in_its_colours() {
let jpeg = crate::upright_image::tests::with_extended_xmp_chunks(&flat_four_component_jpeg([255, 0, 0, 0], 0), 5_000);
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut d = image_dict(8, 8, "DeviceCMYK", 8);
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64])));
d.set(b"SMask".to_vec(), Object::Ref(m));
Stream::from_raw(d, jpeg.clone())
});
let (_, rgba) = exported_rgba(&doc);
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{:?}", &rgba[..4]);
}
/// Stripping leaves everything but the Extended XMP chunks, byte for byte.
#[test]
fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() {
let plain = flat_four_component_jpeg([255, 0, 0, 0], 0);
let flooded = crate::upright_image::tests::with_extended_xmp_chunks(&plain, 3);
assert_ne!(flooded, plain);
// The helper puts a stray byte before the chunks, which the decoder skips: it stays.
assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat());
}
+53 -18
View File
@@ -30,7 +30,7 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
}
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS {
if !bytes.starts_with(&[0xFF, 0xD8]) || !decodable_metadata(bytes) {
return None;
}
#[cfg(test)]
@@ -65,10 +65,41 @@ fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
/// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number.
const MAX_EXTENDED_XMP_CHUNKS: usize = 1024;
/// Whether JPEG `bytes` may be handed to a decoder (`image`'s or `zune-jpeg`'s, which `image` uses),
/// headers included: not when it carries more than [`MAX_EXTENDED_XMP_CHUNKS`]. Every path that
/// decodes a JPEG from a file checks this first: image import here, and image export.
pub(crate) fn decodable_metadata(bytes: &[u8]) -> bool {
extended_xmp_chunks(bytes) <= MAX_EXTENDED_XMP_CHUNKS
}
/// How many Extended XMP chunks (APP1 segments) come before the image data.
fn extended_xmp_chunks(bytes: &[u8]) -> usize {
let mut chunks = 0usize;
each_extended_xmp(bytes, |_| chunks += 1);
chunks
}
/// JPEG `bytes` without their Extended XMP chunks, which nothing here reads: what is left decodes
/// in time linear in its size however many chunks the file carried.
pub(crate) fn without_extended_xmp(bytes: &[u8]) -> Vec<u8> {
// Only the bytes that are kept: a CMYK JPEG can be mostly Extended XMP, and reserving the
// whole file for the little that remains aborts the export under a tight address-space limit.
let mut removed = 0usize;
each_extended_xmp(bytes, |r| removed = removed.saturating_add(r.len()));
let mut out = Vec::with_capacity(bytes.len().saturating_sub(removed));
let mut at = 0;
each_extended_xmp(bytes, |r| {
out.extend_from_slice(bytes.get(at..r.start).unwrap_or_default());
at = r.end;
});
out.extend_from_slice(bytes.get(at..).unwrap_or_default());
out
}
/// Calls `f` with the byte range of each Extended XMP chunk (APP1 segment) before the image data.
fn each_extended_xmp(bytes: &[u8], mut f: impl FnMut(std::ops::Range<usize>)) {
const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
let (mut i, mut chunks) = (2usize, 0usize);
let mut i = 2usize;
while let Some(&byte) = bytes.get(i) {
// Stray bytes between segments, which the decoder skips too.
if byte != 0xFF {
@@ -86,14 +117,16 @@ fn extended_xmp_chunks(bytes: &[u8]) -> usize {
_ => {
let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break };
let len = usize::from(u16::from_be_bytes([hi, lo]));
if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) {
chunks += 1;
let end = i.saturating_add(2).saturating_add(len.max(2));
// The prefix must start the APP1 segment's own payload: a zero-length header
// followed by prefix bytes is not a chunk.
if marker == 0xE1 && bytes.get(i.saturating_add(4)..end.min(bytes.len())).is_some_and(|seg| seg.starts_with(PREFIX)) {
f(i..end.min(bytes.len()));
}
i = i.saturating_add(2).saturating_add(len.max(2));
i = end;
}
}
}
chunks
}
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
@@ -120,7 +153,7 @@ fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::tests::phone_jpeg;
@@ -138,22 +171,24 @@ mod tests {
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
/// `jpeg` with `n` empty chunks of one never-complete Extended XMP packet after its SOI.
pub(crate) fn with_extended_xmp_chunks(jpeg: &[u8], n: usize) -> Vec<u8> {
let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID
seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length
seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset
let mut chunk = vec![0xFF, 0xE1];
chunk.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes());
chunk.extend_from_slice(&seg);
[&jpeg[..2], b"\0".as_slice(), &chunk.repeat(n), &jpeg[2..]].concat()
}
#[test]
fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() {
// Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every
// chunk so far after each marker, quadratic in their number.
let chunk = |n: usize| {
let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID
seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length
seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset
let mut out = vec![0xFF, 0xE1];
out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes());
out.extend_from_slice(&seg);
out.repeat(n)
};
let photo = phone_jpeg(6, 144);
let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat();
let with = |n: usize| with_extended_xmp_chunks(&photo, n);
DECODES.with(|c| c.set(0));
assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo");
assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_)));
+57 -4
View File
@@ -234,6 +234,42 @@ impl SecurityHandler {
Ok(Self::new(dict, key, auth, recovered_user))
}
/// A handler with no file key, for a document opened without a password whose strings and
/// streams aren't encrypted and whose encrypted crypt filters are all ones the caller found
/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6,
/// Table 25). Data such a filter encrypts is left as stored, read and written alike, so it
/// keeps its encrypted bytes; [`SecurityHandler::decrypts`] tells which. `None` when strings or
/// streams are encrypted, which needs the key, or the handler is older than crypt filters (V4).
pub fn without_key(dict: EncryptDict) -> Option<Self> {
if dict.v < 4 {
return None;
}
let methods = Methods::new(&dict);
if methods.string != Method::Identity || methods.stream != Method::Identity {
return None;
}
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
}
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
pub fn has_key(&self) -> bool {
!self.key.is_empty()
}
/// Whether data of `kind` is encrypted, so that reading it needs the file key.
pub fn decrypts(&self, kind: StreamKind) -> bool {
self.method(Some(kind)) != Method::Identity
}
/// Whether data left as stored by a handler without the key still decrypts after its object
/// is renumbered: only AES-256 doesn't mix the object number into the key (Algorithm 1.A).
pub fn survives_renumbering(&self) -> bool {
[self.methods.string, self.methods.stream, self.methods.embedded_file]
.into_iter()
.chain(self.methods.named.values().copied())
.all(|m| matches!(m, Method::Identity | Method::Aes256))
}
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
/// document is unlocked after opening. The user password, or any other, is `WrongPassword`.
/// Both passwords unlock the same file key, so what was decrypted so far stays valid; a
@@ -267,7 +303,7 @@ impl SecurityHandler {
/// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
fn perms_p(&self) -> Option<i32> {
if !matches!(self.dict.r, 5 | 6) {
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
return None;
}
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
@@ -302,23 +338,40 @@ impl SecurityHandler {
}
}
/// Algorithm 1 (§7.6.3.3): the key for one object. AES-256 uses the file key directly.
/// Algorithm 1 (§7.6.3.2): the key for one object. AES-256 uses the file key directly
/// (Algorithm 1.A, §7.6.3.3).
///
/// For an RC4 crypt filter under `/V 4`, a file key shorter than 16 bytes is first extended
/// to 16 bytes with zeros. The standard doesn't say so (Algorithm 1 takes the n-byte key as
/// it is), but that is how Acrobat writes and reads such files: a `/V 4` RC4 crypt filter
/// with a 40- or 48-bit key (pdf.js issue 19484). The renderer's `hayro-syntax` reads them
/// the same way, so a page it shows is one this crate can read and save. AES-128 keys are
/// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only
/// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already.
fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec<u8> {
if method == Method::Aes256 {
return self.key.clone();
}
let mut key = self.key.clone();
if method == Method::Rc4 && self.dict.v == 4 && key.len() < 16 {
key.resize(16, 0);
}
let mut h = Md5::new();
h.update(&self.key);
h.update(&key);
h.update(&num.to_le_bytes()[..3]);
h.update(generation.to_le_bytes());
if method == Method::Aes128 {
h.update(b"sAlT");
}
let digest = h.finalize();
digest[..(self.key.len() + 5).min(16)].to_vec()
digest[..(key.len() + 5).min(16)].to_vec()
}
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
// Without the key, encrypted data stays as stored (see `without_key`).
if !self.has_key() {
return data.to_vec();
}
match method {
Method::Identity => data.to_vec(),
Method::Rc4 => rc4(&self.object_key(method, num, generation), data),
+63
View File
@@ -280,6 +280,69 @@ proptest::proptest! {
}
}
/// A `/V 4` RC4 crypt filter with a 40- or 48-bit key, as Acrobat writes it (pdf.js issue 19484):
/// the file key is extended to 16 bytes with zeros before Algorithm 1, so each object's key is
/// all 16 bytes of MD5(key, zeros, object, generation). Everything else keeps Algorithm 1 as the
/// standard has it, n + 5 bytes (at most 16) of MD5(key, object, generation[, "sAlT"]): a `/V 2`
/// file with the same short key, a full 128-bit `/V 4` RC4 key, and AESV2, short (88 bits) or
/// full. Each object key is computed here by hand; data written with it must decrypt.
#[test]
fn a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros() {
let text = b"Page tree in an object stream".to_vec();
let cases =
[(4, 40, Method::Rc4), (4, 48, Method::Rc4), (2, 40, Method::Rc4), (4, 128, Method::Rc4), (4, 88, Method::Aes128), (4, 128, Method::Aes128)];
for (v, bits, method) in cases {
let mut d = EncryptDict {
filter: b"Standard".to_vec(),
v,
r: if v == 4 { 4 } else { 3 },
length_bits: bits,
o: vec![7; 32],
p: -3392,
encrypt_metadata: v != 4,
..Default::default()
};
if v == 4 {
d.crypt_filters = vec![(b"StdCF".to_vec(), method)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
}
let key = file_key_r4(&d, ID, b"");
assert_eq!(key.len(), (bits / 8) as usize);
d.u = u_value_r4(&d, ID, &key);
let h = SecurityHandler::open(d, ID, None).unwrap();
let (num, generation) = (13u32, 0u16);
let extended = v == 4 && method == Method::Rc4 && key.len() < 16;
let mut md5 = Md5::new();
md5.update(&key);
if extended {
md5.update(vec![0u8; 16 - key.len()]);
}
md5.update(&num.to_le_bytes()[..3]);
md5.update(generation.to_le_bytes());
if method == Method::Aes128 {
md5.update(b"sAlT");
}
let digest = md5.finalize();
let n = if extended { 16 } else { key.len() };
let object_key = digest[..(n + 5).min(16)].to_vec();
let case = format!("V{v}, {bits}-bit key, {method:?}");
let written = if method == Method::Rc4 {
rc4(&object_key, &text)
} else {
let iv = [3u8; 16];
[iv.to_vec(), aes_cbc_encrypt(&object_key, &iv, &text, true)].concat()
};
assert_eq!(h.decrypt_string(num, generation, &written), text, "{case}");
assert_eq!(h.decrypt_stream(num, generation, &written, StreamKind::Normal), text, "{case}");
let saved = h.encrypt_stream(num, generation, &text, StreamKind::Normal);
if method == Method::Rc4 {
assert_eq!(saved, written, "saved as it was read: {case}");
}
assert_eq!(h.decrypt_stream(num, generation, &saved, StreamKind::Normal), text, "{case}");
}
}
#[test]
fn owner_authentication_recovers_the_user_password() {
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
+3
View File
@@ -21,6 +21,9 @@ kurbo = { workspace = true }
# Display order of right-to-left lines in added text, and reading and writing order in Edit text;
# already in the tree via the interface.
unicode-bidi = "0.3"
# Thai word boundaries for rewrapping and Add text (dictionary data compiled in, no LSTM model);
# the ICU4X 2.3 crates it builds on are already in the tree.
icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] }
[dev-dependencies]
hayro-syntax = { workspace = true }
+8 -5
View File
@@ -8,12 +8,8 @@
//! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the
//! `/UserUnit` it was written for.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi,
};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged};
@@ -23,13 +19,18 @@ mod layout;
mod store;
mod types;
pub(crate) use draw::thai_glyph_resources;
#[cfg(test)]
use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped};
#[cfg(test)]
use layout::Piece;
#[cfg(test)]
pub(crate) use layout::WRAP_CALLS;
#[cfg(test)]
pub(crate) use layout::shaped_width;
pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width};
#[cfg(test)]
use pdfcraft_fonts::ShapedCluster;
pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};
pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan};
@@ -87,3 +88,5 @@ fn align_from(code: Option<i64>) -> Align {
#[cfg(test)]
mod arabic_tests;
#[cfg(test)]
mod thai_tests;
+74 -165
View File
@@ -3,17 +3,17 @@ use super::layout::{
};
use super::types::{AddedText, Align, Family, Ink};
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
Content, Dict, Document, EditError, GlyphError, GlyphOutline, Object, PdfString, ShapedScript, align_code, literal, matrix_number, n,
shaped_glyph, win_ansi,
};
use pdfcraft_fonts::shaped_text::{self, ShapedFonts};
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;
#[cfg(test)]
pub(super) use pdfcraft_fonts::shaped_text::GLYPHS_PER_FONT;
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its paragraph, its pieces in drawing order and its width at the item's size.
@@ -61,50 +61,15 @@ pub(super) fn draw_laid_shaped(
) -> 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 shaped =
ShapedFonts::new(shaped_text::clusters(laid.iter().map(|(_, _, _, pieces, _)| pieces.as_slice())), outline, taken, fonts, "PCAr", &mut |o| {
Object::Ref(doc.add(o))
})
.map_err(|(e, c)| match c.script {
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Hebrew => hebrew_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
})?;
out.extend(format!("BT {}\n", t.fill_op()).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
@@ -119,32 +84,17 @@ pub(super) fn draw_laid_shaped(
let (x, tw) = placed(t, r, &paragraphs, i, *w, spaces, *rtl);
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
let mut pen = x;
let mut current: Option<&str> = None;
let mut current = 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());
current = None;
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 };
}
}
Piece::Shaped(clusters) => pen = shaped.show(out, clusters, pen, y, t.size, t.char_spacing, tw, &mut current),
}
}
out.extend_from_slice(b"EMC\n");
@@ -153,104 +103,6 @@ pub(super) fn draw_laid_shaped(
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
@@ -390,3 +242,60 @@ pub(super) fn params(c: &Content) -> Dict {
}
d
}
/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline
/// writer instead of trying to embed the matching craft-fonts variable program as a static face.
pub(crate) fn thai_glyph_resources(
doc: &mut Document,
fonts: &mut Dict,
shaped: &pdfcraft_fonts::source_shaping::ShapedRun,
) -> Result<Vec<(String, Vec<u8>)>, EditError> {
if shaped.glyphs.len() > 65_534 {
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
}
let mut outlines = std::collections::HashMap::new();
let mut clusters = Vec::new();
let mut index = std::collections::HashMap::new();
let mut indices = Vec::new();
for glyph in &shaped.glyphs {
let id = glyph.id.to_u32();
let key = (ShapedScript::Thai, id);
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) {
entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?);
}
let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| {
let at = clusters.len();
let width = outlines.get(&key).map_or(0.0, |g| g.width);
clusters.push(pdfcraft_fonts::ShapedCluster {
script: ShapedScript::Thai,
glyphs: vec![(id, [0.0, 0.0])],
advance: width,
text: glyph.text.clone(),
});
at
});
indices.push(at);
}
// Validate all outlines before adding resources. Type 3 character codes are single bytes;
// shard long rows and return the appropriate resource/code for every positioned glyph.
let mut names = Vec::new();
for chunk in clusters.chunks(shaped_text::GLYPHS_PER_FONT) {
let refs: Vec<_> = chunk.iter().collect();
let font = shaped_text::add_type3_font(doc, &refs, &outlines);
doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?;
let mut name = format!("PCThai{}", font.num);
while fonts.contains(name.as_bytes()) {
name.push('_');
}
fonts.set(name.as_bytes().to_vec(), Object::Ref(font));
names.push(name);
}
indices
.into_iter()
.map(|at| {
let name = names.get(at / shaped_text::GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?;
let code = u8::try_from(at % shaped_text::GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?;
Ok((name.clone(), vec![code]))
})
.collect()
}
+135 -47
View File
@@ -1,7 +1,6 @@
use super::types::{AddedImage, AddedText};
use super::{
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew,
shape_thai,
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedScript, shape_arabic, shape_hebrew, shape_thai,
};
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
@@ -55,16 +54,21 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
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);
// Widths add up (the standard-14 widths have no kerning), so each word is measured once
// and appended in place: a line that never fills (negative character spacing) stays
// linear instead of being copied and measured again for every word.
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) };
let word_w = measure(word);
let candidate_w = if line.is_empty() { word_w } else { line_w + space + word_w };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl, index));
line = word.to_string();
line_w = measure(word);
line.push_str(word);
line_w = word_w;
} else {
line = candidate;
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
line_w = candidate_w;
}
}
@@ -73,8 +77,11 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
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.
/// Thai wrapping: a row ends before a word that would overflow it, the words found by the
/// dictionary ([`crate::linebreak`]) inside runs without spaces and at the spaces between them. The
/// spaces where a row ends are dropped, as in the Latin wrapper. Only a word wider than the row is
/// broken inside, at shaped cluster boundaries, which never detach a tone or vowel mark from its
/// base.
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();
@@ -88,24 +95,88 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, Edit
})),
}
}
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)
Ok(wrap_words(thai_words(atoms), width))
}
pub(super) fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
/// `words` (each a list of text and advance, with the spaces that follow it) laid into rows no
/// wider than `width`: a word that doesn't fit what is left of a row starts the next one, and only
/// a word wider than a whole row is split, between its clusters.
fn wrap_words(words: Vec<Vec<(String, f64)>>, width: f64) -> Vec<String> {
let blank = |s: &str| s.chars().all(char::is_whitespace);
let mut lines: Vec<String> = Vec::new();
let (mut line, mut used) = (String::new(), 0.0);
let end_row = |lines: &mut Vec<String>, line: &mut String| {
let done = std::mem::take(line);
lines.push(done.trim_end().to_string());
};
for word in words {
// The word's own width: the spaces after it may hang past the row's end.
let ink: f64 = word.iter().filter(|(s, _)| !blank(s)).map(|(_, w)| w).sum();
// A row holding only the spaces that open the paragraph has nothing to end yet.
if !blank(&line) && used + ink > width {
end_row(&mut lines, &mut line);
used = 0.0;
}
// Too wide for what is left of the row, counting the spaces that open the paragraph.
let too_wide = blank(&line) && used + ink > width;
for (text, advance) in word {
if too_wide && !blank(&line) && !blank(&text) && used + advance > width {
end_row(&mut lines, &mut line);
used = 0.0;
}
// A row after a wrap does not begin with the spaces where the last one ended.
if line.is_empty() && !lines.is_empty() && blank(&text) {
continue;
}
line.push_str(&text);
used += advance;
}
}
lines.push(line);
lines
}
/// The shaped clusters `atoms` (text and advance) grouped into words: a word starts after spaces,
/// or where [`crate::linebreak::breaks`] lets a row end inside a run without spaces, and keeps the
/// spaces that follow it. A break that falls inside a cluster is not one.
fn thai_words(atoms: Vec<(String, f64)>) -> Vec<Vec<(String, f64)>> {
let text: String = atoms.iter().map(|(s, _)| s.as_str()).collect();
let mut cuts = Vec::new();
let mut run_start: Option<usize> = None;
for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
match (c.is_whitespace(), run_start) {
(false, None) => {
cuts.push(at);
run_start = Some(at);
}
(true, Some(start)) => {
let run = text.get(start..at).unwrap_or_default();
cuts.extend(crate::linebreak::breaks(run).into_iter().map(|b| start.saturating_add(b)));
run_start = None;
}
_ => {}
}
}
// `cuts` is in order: each run's start, then the breaks inside it, then the next run's start.
let mut words: Vec<Vec<(String, f64)>> = Vec::new();
let mut cuts = cuts.into_iter().peekable();
let mut offset = 0usize;
for atom in atoms {
while cuts.peek().is_some_and(|&c| c < offset) {
cuts.next();
}
let starts_word = cuts.peek() == Some(&offset);
offset = offset.saturating_add(atom.0.len());
match words.last_mut() {
Some(word) if !starts_word => word.push(atom),
_ => words.push(vec![atom]),
}
}
words
}
pub(super) use pdfcraft_fonts::shaped_text::{Piece, is_thai};
/// 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 {
@@ -184,13 +255,6 @@ fn mirrored(c: char) -> char {
}
}
/// 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> {
@@ -244,20 +308,13 @@ pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec
});
}
}
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();
let width = pdfcraft_fonts::shaped_text::pieces_width(&pieces, t.size, t.char_spacing, |s, _| latin_width(t, s));
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 {
pub(crate) 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))
}
@@ -282,13 +339,7 @@ pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError {
}
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(),
})
EditError::Invalid(pdfcraft_fonts::shaped_text::thai_message(e, text))
}
/// The box a text item occupies (height from its lines).
@@ -357,3 +408,40 @@ fn copy_key(c: &Content) -> Content {
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)
}
#[cfg(test)]
mod thai_wrap_tests {
use super::{thai_words, wrap_words};
/// One atom per character, each `advance` wide: what shaping hands over, without a Thai face.
fn atoms(text: &str, advance: f64) -> Vec<(String, f64)> {
text.chars().map(|c| (c.to_string(), advance)).collect()
}
fn texts(words: &[Vec<(String, f64)>]) -> Vec<String> {
words.iter().map(|w| w.iter().map(|(s, _)| s.as_str()).collect()).collect()
}
#[test]
fn thai_atoms_group_into_dictionary_words_with_their_spaces() {
assert_eq!(texts(&thai_words(atoms("น้ำประปาสะอาด", 1.0))), ["น้ำ", "ประปา", "สะอาด"]);
assert_eq!(texts(&thai_words(atoms("สวัสดี ครับ", 1.0))), ["สวัสดี ", "ครับ"]);
// A break the dictionary finds inside one shaped cluster is not a place to end a row.
assert_eq!(texts(&thai_words(vec![("น้ำประปา".into(), 8.0)])), ["น้ำประปา"]);
assert!(thai_words(Vec::new()).is_empty());
}
#[test]
fn words_wrap_whole_and_only_an_overlong_word_is_split() {
let words = || vec![vec![("ab".into(), 2.0), (" ".into(), 1.0)], vec![("cd".into(), 2.0)]];
assert_eq!(wrap_words(words(), 10.0), ["ab cd"]);
// The space that ends a row hangs past it, and the next row doesn't begin with it.
assert_eq!(wrap_words(words(), 3.0), ["ab", "cd"]);
let long = vec![vec![("a".into(), 2.0), ("b".into(), 2.0), ("c".into(), 2.0)]];
assert_eq!(wrap_words(long, 3.0), ["a", "b", "c"]);
// Spaces that open a paragraph stay, and a word too wide for the rest of their row is split.
let opening = vec![vec![(" ".into(), 1.0)], vec![("a".into(), 2.0), ("b".into(), 2.0)]];
assert_eq!(wrap_words(opening, 3.0), [" a", "b"]);
assert_eq!(wrap_words(Vec::new(), 3.0), [""]);
}
}
+9 -2
View File
@@ -328,8 +328,15 @@ pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<
/// 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()
// The stream is one this writer put there, so its decode is bounded by the stream itself: the
// page limit alone would refuse an item larger than a page's content, and its fonts would stay
// on the page's /Font, which `write` and `delete_content` are supposed to take them off.
let limit = pdfcraft_cos::MAX_PAGE_DECODED.max(s.raw.len().saturating_mul(16));
let Ok(data) = s.decoded_within(limit) else { return Vec::new() };
// Each name once: a stream that repeats a name is not copied out once per use.
let names: std::collections::BTreeSet<&[u8]> =
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).collect();
names.into_iter().map(<[u8]>::to_vec).collect()
}
fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
+59
View File
@@ -0,0 +1,59 @@
//! Existing Thai text drawn from the bundled Thai outlines (`thai_glyph_resources`): one Type 3
//! code per distinct glyph and cluster text, in fonts of `GLYPHS_PER_FONT` codes each. The
//! outline checks need the craft-fonts Thai face; the budget check runs without it.
use pdfcraft_cos::{Dict, Document};
use pdfcraft_fonts::source_shaping::{ShapedGlyph, ShapedRun};
use super::*;
/// `count` copies of `glyph`, each with cluster text of its own (so each needs a code of its own).
fn distinct(glyph: &ShapedGlyph, count: usize) -> ShapedRun {
let glyphs = (0..count).map(|i| ShapedGlyph { position: [i as f64, 0.0], text: format!("{i}"), ..glyph.clone() }).collect();
ShapedRun { glyphs, advance: count as f64 }
}
#[test]
fn thai_glyph_resources_refuse_a_row_past_the_glyph_budget() {
let glyph = ShapedGlyph { id: Default::default(), position: [0.0, 0.0], text: String::new() };
let over = ShapedRun { glyphs: vec![glyph; 65_535], advance: 0.0 };
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
assert!(thai_glyph_resources(&mut doc, &mut fonts, &over).is_err());
assert!(fonts.is_empty(), "a refused row adds no font");
}
/// Codes fill a font up to `GLYPHS_PER_FONT`, the next distinct cluster starts another font at
/// code 1, and the same glyph with the same text again reuses its code. One glyph drawn for two
/// cluster texts takes two codes, since each code's ToUnicode gives its own text.
#[test]
fn thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary() {
let Some(face) = pdfcraft_fonts::document_thai_font() else {
eprintln!(
"skipping thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary: built without craft-fonts (set CRAFT_FONTS_DIR to run it)"
);
return;
};
let ko = pdfcraft_fonts::source_shaping::shape(face.bytes, "ก").unwrap().glyphs.remove(0);
for (clusters, fonts_used) in [(GLYPHS_PER_FONT - 1, 1), (GLYPHS_PER_FONT, 1), (GLYPHS_PER_FONT + 1, 2)] {
let mut shaped = distinct(&ko, clusters);
shaped.glyphs.push(shaped.glyphs[0].clone());
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
let placed = thai_glyph_resources(&mut doc, &mut fonts, &shaped).unwrap();
assert_eq!((fonts.len(), placed.len()), (fonts_used, clusters + 1), "{clusters} clusters");
assert_eq!(placed.last(), placed.first(), "a repeated glyph and text reuses its code");
let codes: std::collections::HashSet<_> = placed.iter().collect();
assert_eq!(codes.len(), clusters, "every distinct cluster has a code of its own");
assert_eq!(placed[GLYPHS_PER_FONT - 2].1, [(GLYPHS_PER_FONT - 1) as u8]);
if clusters > GLYPHS_PER_FONT {
assert_eq!(placed[GLYPHS_PER_FONT - 1].1, [GLYPHS_PER_FONT as u8]);
assert_eq!(placed[GLYPHS_PER_FONT].1, [1], "the next font starts at code 1");
assert_ne!(placed[GLYPHS_PER_FONT].0, placed[0].0);
}
}
// At the budget: one cluster repeated 65 534 times needs one code.
let mut row = distinct(&ko, 1);
row.glyphs = vec![row.glyphs[0].clone(); 65_534];
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
let placed = thai_glyph_resources(&mut doc, &mut fonts, &row).unwrap();
assert_eq!((fonts.len(), placed.len()), (1, 65_534));
}
+45 -10
View File
@@ -1,9 +1,12 @@
use pdfcraft_fonts::shaped_text::{is_thai, thai_refusal, thai_width};
use super::marks::{
Background, Context, HeaderFooter, MarkKind, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, shrink_matrix,
Background, Context, HeaderFooter, MarkKind, MarkText, 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,
place_inside_wrapper, stream_tag, undrawable, wrapper_role,
};
use super::{Document, EditError, Object, added, backdrop, helvetica_width};
@@ -31,6 +34,9 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
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(why) = hf.text.iter().find_map(|t| thai_refusal(t)) {
return Err(EditError::Invalid(why));
}
if let Some(c) = hf.first_undrawable() {
return Err(undrawable(c));
}
@@ -73,12 +79,23 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
}
let all = page_list(doc);
let count = all.len();
let number_of = |k: usize| {
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_of = |k: usize| bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()));
// Every Thai line drawn on any page (a Bates prefix may join a template's Thai), for its fonts.
let mut thai_lines = std::collections::BTreeSet::new();
if hf.text.iter().any(|t| t.chars().any(is_thai)) {
for k in 0..pages.len() {
let (number, bates) = (number_of(k)?, bates_of(k)?);
thai_lines.extend(hf.text.iter().flat_map(|t| expand(t, number, count, bates, cx).lines().map(str::to_owned).collect::<Vec<_>>()));
}
}
let thai = MarkText::new(doc, &all, pages, thai_lines.iter().map(String::as_str))?;
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 (number, bates) = (number_of(k)?, bates_of(k)?);
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
@@ -91,6 +108,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
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 current = None;
let mut underlines = String::new();
for (slot, template) in hf.text.iter().enumerate() {
if template.trim().is_empty() {
@@ -101,7 +119,8 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
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 face = |s: &str, size: f64| hf.font.width(s, size, false, false);
let tw = thai_width(line, size, face);
let x = match slot % 3 {
0 => left,
1 => (w - tw) / 2.0,
@@ -109,7 +128,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
};
// 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));
thai.show(&mut body, line, x, y, size, &resource, |s| face(s, size), &mut current);
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))));
}
@@ -126,6 +145,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
if !page_fonts(doc, page).contains(resource.as_bytes()) {
add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?;
}
thai.add_to(doc, &page_list(doc)[i])?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
if let Some(Some(m)) = shrinks.get(k) {
@@ -150,6 +170,9 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
if text.is_empty() && wm.source.is_none() {
return Err(EditError::Invalid("type the watermark text first".into()));
}
if let Some(why) = wm.source.is_none().then(|| thai_refusal(text)).flatten() {
return Err(EditError::Invalid(why));
}
if let Some(c) = wm.first_undrawable() {
return Err(undrawable(c));
}
@@ -171,6 +194,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
}
let opacity = wm.opacity.clamp(0.0, 1.0);
let lines: Vec<&str> = text.lines().collect();
let thai = if wm.source.is_none() { MarkText::new(doc, &page_list(doc), pages, lines.iter().copied())? } else { MarkText::default() };
for &i in pages {
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
@@ -185,7 +209,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
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 widest = lines.iter().map(|l| thai_width(l, 1.0, helvetica_width)).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]);
@@ -207,13 +231,24 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
);
let line_h = size * 1.15;
let total = line_h * (lines.len() as f64 - 1.0);
let mut current = None;
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));
let tw = thai_width(line, size, helvetica_width);
thai.show(
&mut content,
line,
-tw / 2.0,
total / 2.0 - li as f64 * line_h - size * 0.35,
size,
"PCHelv",
|s| helvetica_width(s, size),
&mut current,
);
}
content.extend_from_slice(b"ET\n");
content.extend_from_slice(END.as_bytes());
add_resources(doc, &page, Some(opacity), None)?;
thai.add_to(doc, &page_list(doc)[i])?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Watermark, content, wm.behind)?;
}
+4 -4
View File
@@ -208,7 +208,7 @@ pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result
let same = |w: &ObjRef| match &*doc.get(*w) {
Object::Stream(s) => {
[TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok().as_deref() == Some(bytes.as_slice())
}
_ => false,
};
@@ -248,7 +248,7 @@ fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> O
return None;
}
let hash = note.get(b"Before")?.as_string()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok()?;
let parsed = pdfcraft_content::parse(head(&bytes));
// The `n` standing in for the fill, after exactly the operators that came before it.
let fill = parsed.ops.get(index)?;
@@ -384,11 +384,11 @@ fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>)
if b.len() > SCAN_BYTES.saturating_sub(total) {
return None;
}
budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()?
budget.decode_strict(doc, &Stream::from_raw(Dict::new(), b.to_vec())).ok()?
}
// Tolerant decoding can return an empty or partial stream after corruption.
// That cannot prove which paint preceded the candidate backdrop.
_ => budget.decode_strict(s).ok()?,
_ => budget.decode_strict(doc, s).ok()?,
};
if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) {
return None;
+2 -2
View File
@@ -1,7 +1,7 @@
//! A page's background colour: the colour the page is painted with before anything else, as
//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992).
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_content::{Matrix, Op};
use pdfcraft_cos::{Dict, Document};
use crate::{EditError, Rgb};
@@ -51,7 +51,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) };
+225 -21
View File
@@ -136,6 +136,160 @@ fn xobject_name(existing: &Dict, pending: &Dict, n: &mut u32) -> String {
}
}
/// Copy usable property resources, including inherited resources supplied by the page model.
/// Invalid data must stay intact rather than being replaced while transferring membership.
fn properties(doc: &Document, page: &Dict) -> Option<Dict> {
let Some(resources) = page.get(b"Resources") else { return Some(Dict::new()) };
let resources = doc.resolve(resources);
let Object::Dict(resources) = &*resources else { return None };
let Some(properties) = resources.get(b"Properties") else { return Some(Dict::new()) };
match &*doc.resolve(properties) {
Object::Dict(properties) => Some(properties.clone()),
_ => None,
}
}
/// Validate an ISO 32000-1 §8.11.2.2 `/VE` visibility expression within recursion depth 16
/// and a shared node-visit `budget`.
fn valid_ve(doc: &Document, expr: &Object, depth: usize, budget: &mut usize) -> bool {
if depth > 16 || *budget == 0 {
return false;
}
*budget -= 1;
let resolved = doc.resolve(expr);
if let Some(arr) = resolved.as_array() {
let [op, rest @ ..] = arr.as_slice() else { return false };
let op_obj = doc.resolve(op);
let Some(name) = op_obj.as_name() else { return false };
match (name, rest) {
(b"Not", [operand]) => valid_ve(doc, operand, depth + 1, budget),
(b"And" | b"Or", [_, ..]) => rest.iter().all(|o| valid_ve(doc, o, depth + 1, budget)),
_ => false,
}
} else if depth > 0 {
expr.as_ref().is_some_and(|r| matches!(&*doc.get(r), Object::Dict(d) if d.name(b"Type") == Some(b"OCG")))
} else {
false
}
}
/// Validate an `/OCMD` dictionary's `/P` policy and `/VE` visibility expression, if present.
fn valid_ocmd(doc: &Document, d: &Dict) -> bool {
if let Some(p) = d.get(b"P")
&& !matches!(doc.resolve(p).as_name(), Some(b"AllOn" | b"AnyOn" | b"AnyOff" | b"AllOff"))
{
return false;
}
if let Some(ve) = d.get(b"VE")
&& !valid_ve(doc, ve, 0, &mut 4096)
{
return false;
}
true
}
/// Preserve the stored value, not a reconstructed membership dictionary. A direct inline
/// dictionary lacks an indirect reference (ObjRef) to identify it in page resources, and an
/// OCMD with a malformed or overly deep `/VE` expression stays on the annotation.
fn transferable(doc: &Document, annotation: &Dict, usable_properties: bool) -> bool {
let Some(membership) = annotation.get(b"OC") else { return true };
if !usable_properties {
return false;
}
match &*doc.resolve(membership) {
Object::Dict(d) => match d.name(b"Type") {
Some(b"OCG") => membership.as_ref().is_some(),
Some(b"OCMD") => valid_ocmd(doc, d),
_ => false,
},
_ => false,
}
}
#[derive(Default)]
struct Retained {
refs: HashSet<ObjRef>,
inline: HashSet<usize>,
}
impl Retained {
fn contains(&self, index: usize, entry: &Object) -> bool {
self.inline.contains(&index) || entry.as_ref().is_some_and(|r| self.refs.contains(&r))
}
fn insert(&mut self, index: usize, entry: &Object) {
if let Some(r) = entry.as_ref() {
self.refs.insert(r);
} else {
self.inline.insert(index);
}
}
}
/// Retained annotations keep their replies and pop-ups, including transitive reply chains.
///
/// Retention is strictly downward (parent -> child/reply/popup). A non-transferable child does not
/// keep its transferable parent, matching the engine invariant for undrawn children (#1794).
fn retained_annotations(doc: &Document, list: &[Object], usable_properties: bool) -> Retained {
let mut children: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
let mut inline_children: HashMap<ObjRef, Vec<usize>> = HashMap::new();
let mut pending = Vec::new();
let mut retained = Retained::default();
for (k, entry) in list.iter().enumerate() {
let object = doc.resolve(entry);
let Some(annotation) = object.as_dict() else { continue };
let not_transferable = !transferable(doc, annotation, usable_properties);
if let Some(reference) = entry.as_ref() {
if not_transferable {
pending.push(reference);
}
if let Some(parent) = annotation.reference(b"IRT") {
children.entry(parent).or_default().push(reference);
}
if annotation.name(b"Subtype") == Some(b"Popup")
&& let Some(parent) = annotation.reference(b"Parent")
{
children.entry(parent).or_default().push(reference);
}
} else {
if not_transferable {
retained.inline.insert(k);
}
if let Some(parent) = annotation.reference(b"IRT") {
inline_children.entry(parent).or_default().push(k);
}
if annotation.name(b"Subtype") == Some(b"Popup")
&& let Some(parent) = annotation.reference(b"Parent")
{
inline_children.entry(parent).or_default().push(k);
}
}
}
while let Some(current) = pending.pop() {
if !retained.refs.insert(current) {
continue;
}
if let Some(kids) = children.get(&current) {
pending.extend(kids.iter().copied());
}
if let Some(inline_kids) = inline_children.get(&current) {
retained.inline.extend(inline_kids.iter().copied());
}
}
retained
}
/// A Properties resource name that is not already on the page or in `pending`.
fn property_name(existing: &Dict, pending: &Dict, n: &mut u64) -> Option<String> {
loop {
let name = format!("PCFlOC{n}");
*n = n.checked_add(1)?;
if !existing.contains(name.as_bytes()) && !pending.contains(name.as_bytes()) {
return Some(name);
}
}
}
/// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged
/// into page content.
pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result<usize, EditError> {
@@ -157,10 +311,15 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result<usize, E
fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], which: Which) -> Option<(usize, bool, char)> {
for &i in pages {
let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
for entry in &list {
let usable_properties = properties(doc, &all[i].dict).is_some();
let retained = retained_annotations(doc, &list, usable_properties);
for (k, entry) in list.iter().enumerate() {
if retained.contains(k, entry) {
continue;
}
let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue };
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 {
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) {
continue;
}
let widget = d.name(b"Subtype") == Some(b"Widget");
@@ -174,6 +333,26 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi
None
}
/// Ensure comments without stored appearances have appearances generated before flattening.
fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retained: &HashSet<ObjRef>, usable_properties: bool) {
for entry in list {
let Some(r) = entry.as_ref() else { continue };
if retained.contains(&r) {
continue;
}
let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default();
let subtype = d.name(b"Subtype").unwrap_or_default();
if subtype == b"Widget" || !selected(doc, &d, which) || !transferable(doc, &d, usable_properties) {
continue;
}
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
let _ = pdfcraft_annot::set_appearance(doc, r);
}
}
/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on
/// success, so a page refused part way leaves the pages before it as they were.
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
@@ -183,6 +362,19 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
Ok(drawn)
}
/// `page` with what its own dictionary holds now, after an edit: re-reading this one page,
/// where walking the whole page tree again for every flattened page would be quadratic.
/// Inherited attributes stay as they were collected, under the page's own entries.
fn refreshed(doc: &Document, page: &pdfcraft_model::Page) -> pdfcraft_model::Page {
let mut dict = page.dict.clone();
if let Some(own) = doc.get(page.obj).as_dict() {
for (k, v) in own.iter() {
dict.set(k.clone(), v.clone());
}
}
pdfcraft_model::Page { obj: page.obj, dict }
}
fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
@@ -205,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
let Some(list) = annots_obj.as_ref().map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue };
let mut content = String::new();
let mut xobjects = Dict::new();
let existing_properties = properties(doc, &page.dict);
let mut added_properties = Dict::new();
let mut retained = retained_annotations(doc, &list, existing_properties.is_some());
let mut property_n = 0u64;
let mut name_n = 0u32;
let existing_xo = page
.dict
@@ -221,27 +417,16 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
// A viewer draws many comments from their dictionary even when the file stores no
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
// flattening keeps a comment that still has ink on screen instead of deleting it.
for entry in &list {
let Some(r) = entry.as_ref() else { continue };
let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default();
let subtype = d.name(b"Subtype").unwrap_or_default();
if subtype == b"Widget" || !selected(doc, &d, which) {
continue;
}
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw fails here and is kept below, so the comment is
// not deleted with nothing drawn in its place. (Text the standard fonts can't draw
// was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
prepare_appearances(doc, &list, which, &retained.refs, existing_properties.is_some());
for (k, entry) in list.iter().enumerate() {
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict() else { continue };
let subtype = d.name(b"Subtype").unwrap_or_default();
let widget = subtype == b"Widget";
if retained.contains(k, entry) || !transferable(doc, d, existing_properties.is_some()) {
retained.insert(k, entry);
continue;
}
if !selected(doc, d, which) {
continue;
}
@@ -255,8 +440,20 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
if let Some(m) = placement(doc, &form, rect) {
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
if let Some(membership) = d.get(b"OC") {
let Some(existing) = existing_properties.as_ref() else { continue };
let Some(name) = property_name(existing, &added_properties, &mut property_n) else {
retained.insert(k, entry);
continue;
};
content.push_str(&format!("/OC /{name} BDC\n"));
added_properties.set(name.into_bytes(), membership.clone());
}
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
if d.contains(b"OC") {
content.push_str("EMC\n");
}
xobjects.set(name.into_bytes(), Object::Ref(ap));
drawn += 1;
drew = true;
@@ -297,7 +494,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
// it could not be drawn: its content would go with nothing in its place.
for entry in &list {
let Some(r) = entry.as_ref() else { continue };
if undrawn.contains(&r) {
if undrawn.contains(&r) || retained.refs.contains(&r) {
continue;
}
let obj = doc.get(r);
@@ -321,11 +518,18 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
xo.set(k.clone(), v.clone());
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
if !added_properties.is_empty() {
let mut properties = existing_properties.unwrap_or_default();
for (key, value) in added_properties.iter() {
properties.set(key.clone(), value.clone());
}
res.set(b"Properties".to_vec(), Object::Dict(properties));
}
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
let page = page_list(doc)[i].clone();
let page = refreshed(doc, &page);
place_tagged(doc, &page, "Flattened", content.into_bytes(), false)?;
}
let page_now = page_list(doc)[i].clone();
let page_now = refreshed(doc, &page);
match page_now.dict.get(b"Annots").and_then(|a| a.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) {
Some(r) => doc.set(r, Object::Array(kept)),
None => doc.update_dict(page.obj, |d| {
+102 -48
View File
@@ -1,15 +1,15 @@
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form XObjects painted by `Do`
//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
//! or delete. Only the stream that draws it is rewritten, as a new object.
//! delete, or ungroup grouped artwork (its content drawn in place, see `ungroup`). Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
use crate::text::content_streams;
use crate::text::{ContentStream, content_streams, joined};
/// The identity of a soft mask (`/SMask`) in the PDF graphics state.
#[derive(Clone, Debug)]
@@ -47,9 +47,9 @@ pub struct PageImage {
pub width: u32,
pub height: u32,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
/// operators of the page's streams joined ([`pdfcraft_content::Pieces`]).
pub stream: usize,
op: usize,
pub(crate) op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
draw_matrix: [f64; 6],
clips: Vec<ClipPath>,
@@ -103,11 +103,6 @@ fn too_many_images() -> EditError {
EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -470,6 +465,9 @@ pub enum ImageChange {
Arrange(ArrangeOrder),
/// Remove any private crop on the image.
RemoveCrop,
/// Grouped Form artwork only: draw the form's content in place of its `Do`, so its text and
/// pictures become the page's own and can be edited one by one (#2964).
Ungroup,
}
/// The user-space transform that maps box `from` onto box `to` (move and resize).
@@ -532,12 +530,17 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
/// None when its `Q` is at or past `hidden` ([`crate::text::first_op_after_damage`]): a stream
/// between that wasn't read may draw in that `cm` too.
fn enclosing_transform(ops: &[Op], start: usize, end: usize, hidden: usize) -> Option<(usize, Matrix)> {
let at = start.checked_sub(1)?;
let cm = ops.get(at)?;
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
return None;
}
if end.checked_add(1)? >= hidden {
return None;
}
let m = Matrix(cm.nums::<6>()?);
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
}
@@ -1057,7 +1060,9 @@ fn determine_arrange_spans(
has_other_paint: bool,
marked_unit: bool,
) -> Result<(std::ops::Range<usize>, usize, usize, usize), EditError> {
let scope = image_scopes(ops, target, "arranged")
// Nothing is hidden for Arrange: it takes out the scope it finds, so a scope that closes past a
// stream not read whole changes a stream past it, which `rewritten_contents` refuses.
let scope = image_scopes(ops, target, "arranged", ops.len())
.ok()
.and_then(|s| s.isolated)
.filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end);
@@ -1191,13 +1196,7 @@ fn build_relocated_unit_ops(
}
/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`.
fn arrange_image(
doc: &mut Document,
p: &pdfcraft_model::Page,
all: &[(Object, Vec<u8>)],
img: &PageImage,
order: ArrangeOrder,
) -> Result<(), EditError> {
fn arrange_image(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage, order: ArrangeOrder) -> Result<(), EditError> {
let pieces = joined(all);
let ops = pieces.parse().ops;
let target = img.op;
@@ -1264,7 +1263,7 @@ fn arrange_image(
ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start))
};
let owned_destination = destination_piece
.filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
.filter(|piece| all.get(*piece).is_some_and(|s| doc.resolve(&s.entry).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
if let Some(piece) = owned_destination {
let whole_unit = ops.iter().enumerate().all(|(index, op)| {
let (first, last) = pieces.pieces_of(op);
@@ -1439,13 +1438,14 @@ fn arrange_image(
};
if let Some(piece) = owned_destination {
let insertion = piece.saturating_add(usize::from(insert_after));
crate::text::drawn_before_damage(all, insertion)?;
let changed = pieces.splice([(target_span, removal_replacement)]);
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
let mut list = match contents {
Object::Array(list) => list,
one => vec![one],
};
let insertion = piece.saturating_add(usize::from(insert_after));
if insertion > list.len() {
return Err(EditError::Invalid("the destination content stream is missing".into()));
}
@@ -1467,7 +1467,7 @@ fn arrange_image(
}
let changed = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1501,8 +1501,10 @@ impl ImageScopes {
}
}
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`.
fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, EditError> {
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. A scope
/// still open at op `hidden` ([`crate::text::first_op_after_damage`]) holds a stream that wasn't
/// read, which may paint there: it counts as painting something else.
fn image_scopes(ops: &[Op], target: usize, done: &str, hidden: usize) -> Result<ImageScopes, EditError> {
struct Scope {
start: usize,
matrix: Matrix,
@@ -1521,6 +1523,11 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, Ed
let mut isolated = None;
let mut outermost = None;
for (index, op) in ops.iter().enumerate() {
if index == hidden {
for scope in &mut stack {
scope.other_paint = true;
}
}
match op.op.as_slice() {
b"q" => {
if stack.len() >= 256 {
@@ -1606,20 +1613,26 @@ fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec<std::ops::Ran
}
/// Remove only crops written by PdfCraft, preserving the document author's clipping paths.
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec<u8>)], img: &PageImage) -> Result<(), EditError> {
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage) -> Result<(), EditError> {
let pieces = joined(all);
let ops = pieces.parse().ops;
let target = img.op;
let scope = image_scopes(&ops, target, "cropped")?;
let Some((start, _, _)) = scope.transform_scope(target) else {
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
let crops = |hidden| -> Result<Vec<std::ops::Range<usize>>, EditError> {
let scope = image_scopes(&ops, target, "cropped", hidden)?;
Ok(scope.transform_scope(target).map(|(start, _, _)| app_crop_ranges(&ops, start, target)).unwrap_or_default())
};
let crops = app_crop_ranges(&ops, start, target);
if crops.is_empty() {
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
let crops_there = crops(crate::text::first_op_after_damage(all, &pieces, &ops))?;
if crops_there.is_empty() {
// A crop in a scope that closes past a stream not read whole may clip what that stream draws.
return Err(EditError::Invalid(if crops(ops.len())?.is_empty() {
"the image has no PdfCraft crop to remove".into()
} else {
"the image's crop may also clip what a content stream that is damaged or can't be decoded draws, so it isn't removed".into()
}));
}
let crops = crops_there;
let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index))));
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1650,6 +1663,26 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
ImageChange::Crop(keep) => {
return crop_image_impl(doc, &p, &all, &img, *keep);
}
ImageChange::Ungroup => {
if !img.is_form {
return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into()));
}
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let space = crate::ungroup::page_blend_space(doc, &p.dict);
// One allowance for the form and everything it draws.
let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0);
let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } =
crate::ungroup::ungroup_ops(doc, &res, &ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget).map_err(|r| r.error)?;
if !patterns.is_empty() {
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (name, copy) in patterns {
table.set(name, Object::Ref(doc.add(copy)));
}
merged.set(b"Pattern".to_vec(), Object::Dict(table));
}
resources = Some(merged);
inline
}
ImageChange::Replace(r) => {
if img.is_form {
return Err(EditError::Invalid(
@@ -1666,7 +1699,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
}
};
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
let contents = crate::text::rewritten_contents(doc, &all, changed);
let contents = crate::text::rewritten_contents(doc, &all, changed)?;
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
@@ -1687,7 +1720,7 @@ pub(crate) fn selectable_images(
Ok(page_images(doc, page)?
.into_iter()
.enumerate()
.filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
.filter(|(_, image)| !all.get(image.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r)))
.collect())
}
@@ -1700,7 +1733,7 @@ pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: us
fn transform_image(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
streams: &[ContentStream],
image: &PageImage,
transform: [f64; 6],
) -> Result<(), EditError> {
@@ -1715,8 +1748,9 @@ fn transform_image(
}
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let hidden = crate::text::first_op_after_damage(streams, &pieces, &ops);
let (start, end, placement) =
image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
image_scopes(&ops, target, "transformed", hidden)?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let local = placement.then(&Matrix(transform)).then(&inverse);
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
@@ -1725,7 +1759,7 @@ fn transform_image(
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
if let Some((at, cm)) = enclosing_transform(&ops, start, end, hidden) {
let folded = local.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
@@ -1733,7 +1767,7 @@ fn transform_image(
let changed = crate::text::splice(&pieces, &ops, |index| {
if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) }
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
return Ok(());
}
@@ -1754,7 +1788,7 @@ fn transform_image(
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1766,7 +1800,7 @@ fn transform_image(
fn crop_image_impl(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
streams: &[ContentStream],
image: &PageImage,
keep: [f64; 4],
) -> Result<(), EditError> {
@@ -1790,8 +1824,9 @@ fn crop_image_impl(
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
let (start, end, placement) =
image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
let (start, end, placement) = image_scopes(&ops, target, "cropped", crate::text::first_op_after_damage(streams, &pieces, &ops))?
.transform_scope(target)
.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
@@ -1844,11 +1879,29 @@ fn crop_image_impl(
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
/// The `Do` of each image `indexes` (from [`page_images`]) on `page`, as an index among the
/// page's joined operators: what Copy takes of them.
pub(crate) fn image_ops(doc: &Document, page: usize, indexes: &[usize]) -> Result<Vec<usize>, EditError> {
if indexes.is_empty() {
return Ok(Vec::new());
}
let images = page_images(doc, page)?;
indexes
.iter()
.map(|i| {
images
.get(*i)
.map(|image| image.op)
.ok_or_else(|| EditError::Invalid("a selected image no longer exists; select the objects again".into()))
})
.collect()
}
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
@@ -1872,6 +1925,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
}
let pieces = joined(&all);
let ops = pieces.parse().ops;
let hidden = crate::text::first_op_after_damage(&all, &pieces, &ops);
let mut inside_text = false;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
@@ -1889,7 +1943,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
let mut folds = std::collections::BTreeMap::new();
for image in images.iter().filter(|image| image.has_editor_crop()) {
let Some(&(_, _, offset)) = plans.get(&image.op) else { continue };
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else {
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved", hidden)?.transform_scope(image.op) else {
return Err(EditError::Invalid("the image crop cannot be moved independently".into()));
};
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
@@ -1906,7 +1960,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
};
// An enclosing cm is before the captured placement; an inside-scope cm is after it.
// Their multiplication orders differ when that cm scales or shears a translation.
let folded = enclosing_transform(&ops, start, end)
let folded = enclosing_transform(&ops, start, end, hidden)
.map(|(at, cm)| (at, local.then(&cm)))
.or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local))));
if let Some((at, folded)) = folded {
@@ -1942,7 +1996,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index, hidden) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
@@ -1977,7 +2031,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
out.push(Op::new("Q", vec![]));
(out, false)
});
let contents = crate::text::rewritten_contents(doc, &all, data);
let contents = crate::text::rewritten_contents(doc, &all, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+7 -2
View File
@@ -23,7 +23,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
use pdfcraft_fonts::helvetica_width;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
@@ -43,12 +43,17 @@ pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
pub use text::strip_subset_tag;
mod background;
pub use background::page_background;
mod rules;
pub use rules::page_rules;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
pub use objects::{
CopiedObjects, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, copy_objects, editable_objects, move_objects, paste_objects,
};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
mod ungroup;
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
+142 -14
View File
@@ -6,8 +6,56 @@
//! and quotes, currency signs before an amount): the line-start and line-end prohibitions of
//! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR.
//!
//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose
//! words need a dictionary to find.
//! Thai is also written without spaces between words, but a row may not end between any two of its
//! characters: its words need a dictionary to find. They come from ICU4X's dictionary line
//! segmenter (UAX #14 with dictionary breaking for class SA), kept only where a Thai character is on
//! either side. A row never begins with a following vowel, a tone or other mark, Mai Yamok (ๆ) or
//! Paiyannoi (ฯ), nor ends with a leading vowel (เ แ โ ใ ไ), whatever the dictionary says.
//!
//! Korean is written with spaces between words and keeps breaking only there.
use std::sync::LazyLock;
use icu_segmenter::options::LineBreakOptions;
use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed};
/// The dictionary segmenter, built once: its data is compiled in, so building it cannot fail.
static SEGMENTER: LazyLock<LineSegmenterBorrowed<'static>> = LazyLock::new(|| LineSegmenter::new_dictionary(LineBreakOptions::default()));
/// Letters, vowels, marks, digits and signs of the Thai block.
fn thai(c: char) -> bool {
matches!(c, '\u{0e01}'..='\u{0e5b}')
}
/// Thai that belongs to the character before it: Paiyannoi, the following vowels, Lakkhangyao,
/// Mai Yamok and the tone and other marks.
fn thai_no_start(c: char) -> bool {
matches!(c, '\u{0e2f}' | '\u{0e30}'..='\u{0e3a}' | '\u{0e45}'..='\u{0e4e}')
}
/// The leading vowels, written before the consonant they follow in speech.
fn thai_no_end(c: char) -> bool {
matches!(c, '\u{0e40}'..='\u{0e44}')
}
/// Byte offsets where the dictionary lets a row of Thai end inside `word`.
fn thai_breaks(word: &str) -> Vec<usize> {
if !word.chars().any(thai) {
return Vec::new();
}
SEGMENTER
.segment_str(word)
.filter(|&at| {
let before = word.get(..at).and_then(|s| s.chars().next_back());
let after = word.get(at..).and_then(|s| s.chars().next());
match (before, after) {
(Some(b), Some(a)) => (thai(b) || thai(a)) && !thai_no_start(a) && !thai_no_end(b) && !no_start(a) && !no_end(b),
// The start and end of the word are not breaks inside it.
_ => false,
}
})
.collect()
}
/// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID).
fn ideographic(c: char) -> bool {
@@ -77,29 +125,49 @@ fn no_end(c: char) -> bool {
)
}
/// Whether a row may end between `before` and `after`, neither of them a space.
pub(crate) fn between(before: char, after: char) -> bool {
(ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before)
/// Whether a row may end between `before` and `after` in Chinese or Japanese, neither of them a
/// space. Thai needs the whole word ([`breaks`]).
fn between(before: char, after: char) -> bool {
(ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) && !thai_no_start(after) && !thai_no_end(before)
}
/// Byte offsets inside `word` (text without spaces) where a row may end, in order: never 0 or the
/// word's length, always on a character boundary.
pub(crate) fn breaks(word: &str) -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (at, c) in word.char_indices() {
if let Some(before) = prev
&& between(before, c)
{
out.push(at);
}
prev = Some(c);
}
out.extend(thai_breaks(word));
out.sort_unstable();
out.dedup();
out
}
/// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing.
pub(crate) fn pieces(word: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut prev: Option<char> = None;
for (at, c) in word.char_indices() {
if let Some(before) = prev
&& between(before, c)
{
out.extend(word.get(start..at));
start = at;
}
prev = Some(c);
for at in breaks(word) {
out.extend(word.get(start..at));
start = at;
}
out.extend(word.get(start..).filter(|rest| !rest.is_empty()));
out
}
/// For each character of `word` (text without spaces), whether a row may end just before it.
pub(crate) fn breaks_before_chars(word: &str) -> Vec<bool> {
let cuts = breaks(word);
word.char_indices().map(|(at, _)| cuts.binary_search(&at).is_ok()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -121,4 +189,64 @@ mod tests {
assert_eq!(pieces(""), Vec::<&str>::new());
assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字'));
}
#[test]
fn thai_breaks_between_dictionary_words() {
assert_eq!(pieces("ภาษาไทยไม่มีช่องว่างระหว่างคำ"), ["ภาษา", "ไทย", "ไม่มี", "ช่อง", "ว่าง", "ระหว่าง", "คำ"]);
assert_eq!(pieces("น้ำประปาสะอาด"), ["น้ำ", "ประปา", "สะอาด"]);
assert_eq!(pieces("การประปาส่วนภูมิภาค"), ["การ", "ประปา", "ส่วน", "ภูมิภาค"]);
}
#[test]
fn thai_vowels_marks_and_repetition_stay_with_the_word_before_them() {
// The dictionary alone would start a row with Mai Yamok, with the Sara Aa of a decomposed
// Sara Am, or with a closing bracket.
assert_eq!(pieces("เด็กๆชอบกินขนม"), ["เด็กๆ", "ชอบ", "กิน", "ขนม"]);
assert_eq!(pieces("ท\u{0e4d}\u{0e32}งาน"), ["ท\u{0e4d}\u{0e32}", "งาน"]);
assert_eq!(pieces("กรุงเทพฯเป็นเมืองหลวง"), ["กรุงเทพฯ", "เป็น", "เมือง", "หลวง"]);
assert_eq!(pieces("สวัสดีครับ(ทดสอบ)"), ["สวัสดี", "ครับ", "(ทดสอบ)"]);
}
#[test]
fn thai_keeps_numbers_and_latin_whole() {
assert_eq!(pieces("ราคา100บาทต่อเดือน"), ["ราคา", "100บาท", "ต่อ", "เดือน"]);
assert_eq!(pieces("พ.ศ.2569"), ["พ.ศ.2569"]);
assert_eq!(pieces("PDF"), ["PDF"]);
assert_eq!(breaks("ไทย"), Vec::<usize>::new());
}
#[test]
fn breaks_before_chars_marks_the_first_character_of_each_later_piece() {
let flags = breaks_before_chars("น้ำประปา");
let starts: Vec<usize> = flags.iter().enumerate().filter_map(|(i, &b)| b.then_some(i)).collect();
// น ้ ำ | ป ร ะ ป า
assert_eq!(starts, [3]);
assert_eq!(breaks_before_chars(""), Vec::<bool>::new());
}
#[test]
fn pieces_of_arbitrary_thai_rejoin_to_the_input_and_never_start_with_a_mark() {
// A deterministic generator over Thai, Latin, digits and CJK, including mark-only runs and
// unusual orders that no dictionary word has.
let alphabet: Vec<char> = ('\u{0e01}'..='\u{0e5b}').chain(['a', '1', '(', ')', '字', 'の', '.']).collect();
let mut state: u64 = 0x2545_f491_4f6c_dd1d;
for _ in 0..2000 {
let mut word = String::new();
state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let len = (state >> 59) as usize + 1;
for _ in 0..len {
state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let pick = (state >> 33) as usize % alphabet.len();
word.extend(alphabet.get(pick));
}
let parts = pieces(&word);
assert_eq!(parts.concat(), word);
assert!(parts.iter().all(|p| !p.is_empty()), "{word:?} gave {parts:?}");
for part in parts.iter().skip(1) {
let first = part.chars().next().unwrap_or(' ');
assert!(!thai_no_start(first) && !no_start(first), "{word:?} gave {parts:?}");
}
assert_eq!(breaks_before_chars(&word).len(), word.chars().count());
}
}
}
+82 -10
View File
@@ -1,4 +1,75 @@
use super::{Document, EditError, Object, Rgb, added};
use pdfcraft_fonts::shaped_text::{Piece, ShapedFonts, clusters, first_undrawable_in_line, is_thai, thai_message, thai_pieces};
use super::{Dict, Document, EditError, Object, Rgb, added};
/// The Thai of a header, footer or text watermark, drawn from the Thai face as Add text draws it:
/// Type 3 fonts added to the document once and to each page the mark goes on, beside the page
/// font that draws the rest.
#[derive(Default)]
pub(crate) struct MarkText {
shaped: ShapedFonts,
/// The fonts, by the resource names the marked pages give them.
fonts: Dict,
}
impl MarkText {
/// The fonts for the Thai in `lines` (every line the mark draws, on any page), named so that
/// none of `pages` (of `all`) has the name already. Thai this build can't draw (it has no Thai
/// face, or the face lacks a character) is refused, saying what to do.
pub(crate) fn new<'a>(
doc: &mut Document,
all: &[pdfcraft_model::Page],
pages: &[usize],
lines: impl IntoIterator<Item = &'a str>,
) -> Result<Self, EditError> {
let thai = |line: &str| thai_pieces(line).map_err(|e| EditError::Invalid(thai_message(e, line)));
let pieces = lines.into_iter().filter(|l| l.chars().any(is_thai)).map(thai).collect::<Result<Vec<_>, _>>()?;
if pieces.is_empty() {
return Ok(Self::default());
}
let mut taken = Dict::new();
for page in pages.iter().filter_map(|&i| all.get(i)) {
for (name, font) in crate::place::page_fonts(doc, page).iter() {
taken.set(name.clone(), font.clone());
}
}
let mut fonts = Dict::new();
let shaped =
ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), pdfcraft_fonts::shaped_glyph, &taken, &mut fonts, "PCTh", &mut |o| {
Object::Ref(doc.add(o))
})
.map_err(|(e, c)| EditError::Invalid(thai_message(e, &c.text)))?;
Ok(Self { shaped, fonts })
}
/// Show `line` inside `BT … ET` from `x` on baseline `y` at `size`: its Thai from the fonts,
/// the rest in the page font `latin` (selected when `current` is `None`), `width` wide.
#[allow(clippy::too_many_arguments)]
pub(crate) fn show(
&self,
out: &mut Vec<u8>,
line: &str,
x: f64,
y: f64,
size: f64,
latin: &str,
width: impl Fn(&str) -> f64,
current: &mut Option<usize>,
) {
let plain = || vec![Piece::Latin(line.into())];
// `new` shaped every line drawn, so this shapes.
let pieces = if self.fonts.is_empty() || !line.chars().any(is_thai) { plain() } else { thai_pieces(line).unwrap_or_else(|_| plain()) };
self.shaped.show_line(out, &pieces, x, y, size, latin, pdfcraft_fonts::win_ansi, width, current);
}
/// Add the fonts to `page`'s own resources.
pub(crate) fn add_to(&self, doc: &mut Document, page: &pdfcraft_model::Page) -> Result<(), EditError> {
if self.fonts.is_empty() {
return Ok(());
}
crate::place::add_page_fonts(doc, page, &self.fonts)
}
}
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -61,13 +132,13 @@ impl Default for HeaderFooter {
}
impl HeaderFooter {
/// The first character [`add_header_footer`] would refuse, because the standard fonts can't
/// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the
/// template), so checking the templates, split as `expand`'s output is, covers every page
/// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview
/// never offers text that applying refuses (#2013).
/// The first character [`add_header_footer`] would refuse, because neither the standard
/// fonts nor (for Thai) the Thai face can 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)
self.text.iter().flat_map(|t| t.lines()).find_map(first_undrawable_in_line)
}
/// The room the header and the footer take at the top and the bottom of a page, in points:
@@ -139,12 +210,13 @@ pub struct Watermark {
}
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.
/// The first character of a text watermark [`add_watermark`] would refuse, because neither
/// the standard fonts nor (for Thai) the Thai face can 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),
None => self.text.trim().lines().find_map(first_undrawable_in_line),
}
}
}

Some files were not shown because too many files have changed in this diff Show More