Session 4: M1.6 encryption (crypt crate R2-R6), M4 command registry, combine/extract/split, autosave + crash recovery, asset policy + xtask assets, README screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (181)
|
||||
## In this repository (192)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -187,8 +187,19 @@ Every asset PrintCraft includes, bundles or uses to build its published material
|
|||
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space conversion (vendored hayro-interpret) |
|
||||
| `docs/images/printcraft-viewer.png` | PrintCraft viewer screenshot | PrintCraft contributors (generated by `cargo run -p printcraft-ui-egui --example shot`) | MIT OR Apache-2.0 | this repository | README hero image. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-organize.png` | PrintCraft organize screenshot | PrintCraft contributors (generated by `cargo run -p printcraft-ui-egui --example shot`) | MIT OR Apache-2.0 | this repository | README image. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-bookmarks.png` | PrintCraft screenshot: Bookmarks | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: navigation. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-dark.png` | PrintCraft screenshot: Dark theme | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: themes. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-find.png` | PrintCraft screenshot: Find text | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: search. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-forms.png` | PrintCraft screenshot: Interactive form | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: forms. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-layers.png` | PrintCraft screenshot: Layers | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layers. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-organize.png` | PrintCraft screenshot: Organize pages | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: organize. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-palette.png` | PrintCraft screenshot: Command palette | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tools. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-properties.png` | PrintCraft screenshot: Document properties | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: metadata. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-scripts.png` | PrintCraft screenshot: Scripts of the world | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: rendering. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-split.png` | PrintCraft screenshot: Split dialog | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: split. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-tools.png` | PrintCraft screenshot: All tools | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-twoup.png` | PrintCraft screenshot: Two-up reading | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
| `docs/images/printcraft-viewer.png` | PrintCraft screenshot: Viewer with comments | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
|
|
|
|||
165
ATTRIBUTION.toml
|
|
@ -1977,28 +1977,6 @@ kind = "data"
|
|||
usage = "Lab colour space conversion (vendored hayro-interpret)"
|
||||
sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-viewer.png"
|
||||
title = "PrintCraft viewer screenshot"
|
||||
author = "PrintCraft contributors (generated by `cargo run -p printcraft-ui-egui --example shot`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README hero image. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "0b49c1b9b17fc4a6fa7dbb05b72dc675de88683c7d6317423527fd5501ee7beb"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-organize.png"
|
||||
title = "PrintCraft organize screenshot"
|
||||
author = "PrintCraft contributors (generated by `cargo run -p printcraft-ui-egui --example shot`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README image. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "b823fb57adbe88ecaf6a20028e361e9727fda8afbed74dfeba64af513b9a4e6e"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
|
|
@ -2332,3 +2310,146 @@ licence_sha256 = "5b9321a4298cfeb6b34354164a1c3afc3db114569984c502b9b35d988fd58c
|
|||
kind = "font"
|
||||
usage = "Showcase PDF (cargo xtask demo-pdf)"
|
||||
sha256 = "acd98e64795781b2058f07b18475e0ecee2a0fe2b42a49e2f9e37d0d6bf66ce6"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-bookmarks.png"
|
||||
title = "PrintCraft screenshot: Bookmarks"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: navigation. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "3acd5f89182a81b4808436a130d789eeb619ae1ab70d5b8112f0abf68e951555"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-dark.png"
|
||||
title = "PrintCraft screenshot: Dark theme"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: themes. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "4d7c91c1e935ba7ecb7146e128dfc791e17609ec8c053c9e4ca9962ef2c4511f"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-find.png"
|
||||
title = "PrintCraft screenshot: Find text"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: search. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "54282c0ed7be857161f5f7c4142380a832e3eeeb080aec6ccbe5e2efc2c0cb62"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-forms.png"
|
||||
title = "PrintCraft screenshot: Interactive form"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: forms. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "6dcfc0fadaf66a0873984d9b924287be03ecac3b436203fdfce387ab4d5712d3"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-layers.png"
|
||||
title = "PrintCraft screenshot: Layers"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: layers. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "b3587cb6fe878540c41c7fb2819dde1c91b5fe261ee263fbfcc1e306900eeb55"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-organize.png"
|
||||
title = "PrintCraft screenshot: Organize pages"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: organize. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "97a6ed1fc1b053ce4618947665c8092dd8ceffe63ad99983a92672acc0c1dbb1"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-palette.png"
|
||||
title = "PrintCraft screenshot: Command palette"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: tools. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "87be686134c92370f786f110883f38fddb85586c2ccb663db5b64cd693395eb8"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-properties.png"
|
||||
title = "PrintCraft screenshot: Document properties"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: metadata. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "aa2dcccc2f162ec56e5bb04e461e1f2f821df2a4cd2e2472ee43b2f49165959f"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-scripts.png"
|
||||
title = "PrintCraft screenshot: Scripts of the world"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: rendering. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "a1355dbf0542c8fa7aa3a1108b34ae9a4e4b838b7f6264e493a2fa98ed5fdce9"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-split.png"
|
||||
title = "PrintCraft screenshot: Split dialog"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: split. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "1ae6c0d7c00ebab73913d676ddfe522f76a2313a110b54c2c21c3231c6cb0359"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-tools.png"
|
||||
title = "PrintCraft screenshot: All tools"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: tool catalogue. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "fbbbfb988e426da209ab8db5dacbdf1ca15371b0f02ea64b036385535af3da39"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-twoup.png"
|
||||
title = "PrintCraft screenshot: Two-up reading"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README: layouts. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "5c8fa02c7b1c4d548a0a062ae9c984ba857386998bd8426bc685df4c218748eb"
|
||||
|
||||
[[asset]]
|
||||
path = "docs/images/printcraft-viewer.png"
|
||||
title = "PrintCraft screenshot: Viewer with comments"
|
||||
author = "PrintCraft contributors (generated by `cargo xtask screenshots`)"
|
||||
source = "this repository"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "README hero. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"
|
||||
sha256 = "13c0032d140a0b8db98352055864cff65aade8b212efc9ed1166d69512ab755a"
|
||||
|
|
|
|||
|
|
@ -2901,8 +2901,22 @@ name = "printcraft-cos"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"printcraft-crypt",
|
||||
"printcraft-filters",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "printcraft-crypt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
"proptest",
|
||||
"sha2",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
|
@ -2963,6 +2977,7 @@ dependencies = [
|
|||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"printcraft-cos",
|
||||
"printcraft-engine",
|
||||
"printcraft-render",
|
||||
"rfd",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ repository = "https://github.com/storytold/printcraft"
|
|||
printcraft-geom = { path = "crates/geom" }
|
||||
printcraft-filters = { path = "crates/filters" }
|
||||
printcraft-cos = { path = "crates/cos" }
|
||||
printcraft-crypt = { path = "crates/crypt" }
|
||||
printcraft-organize = { path = "crates/organize" }
|
||||
printcraft-render = { path = "crates/render" }
|
||||
printcraft-engine = { path = "crates/engine" }
|
||||
|
|
|
|||
294
README.md
|
|
@ -1,45 +1,277 @@
|
|||
PrintCraft
|
||||
==========
|
||||
<div align="center">
|
||||
|
||||
From the artcraft team
|
||||
# PrintCraft
|
||||
|
||||
A clean-room, open-source PDF application written in Rust, aiming at Adobe Acrobat Pro parity. It runs natively on macOS, Windows and Linux, and in the browser via WebAssembly.
|
||||
**The open-source PDF workbench.**<br>
|
||||
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.<br>
|
||||
macOS · Windows · Linux · the web
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/printcraft-viewer.png" alt="PrintCraft showing the typography showcase PDF, with the All tools panel on the left and threaded comments on the right" width="100%">
|
||||
</p>
|
||||
<sub>From the artcraft team · <a href="#crafting-apps">part of the Crafting Apps family</a></sub>
|
||||
|
||||
- **Engine first.** Parsing, rendering and editing live in library crates. The egui interface is one swappable crate on top.
|
||||
- **Faithful to the file.** The PDF object graph is the document model. Unknown data is preserved. Saves are incremental: the original bytes are kept and your edits are appended.
|
||||
- **Tested against the real world.** The pdf.js test corpus (983 files) is opened, rendered and round-tripped on every change, and qpdf and poppler are used as independent checks.
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/printcraft-organize.png" alt="The Organize pages view: a grid of page thumbnails with rotate, delete, insert and move actions" width="100%">
|
||||
</p>
|
||||
<img src="docs/images/printcraft-viewer.png" alt="PrintCraft showing a typography showcase PDF, with the All tools panel on the left and threaded comments on the right" width="100%">
|
||||
|
||||
## Status
|
||||
<br>
|
||||
|
||||
**Early.** Work so far:
|
||||
- **Viewer:** rendering, text find, select and copy, bookmarks, comments, layers, attachments and form-field highlighting, on desktop and the web.
|
||||
- **Editing:**
|
||||
- Page organizing: rotate, delete, insert and reorder pages.
|
||||
- Document properties.
|
||||
- Undo and redo.
|
||||
- Save and Save As.
|
||||
- A prompt to save unsaved changes before closing.
|
||||
[Highlights](#highlights) ·
|
||||
[Read](#read-anything-beautifully) ·
|
||||
[Organize](#organize-pages-like-cards-on-a-table) ·
|
||||
[Combine & split](#combine-and-split-without-losing-a-thing) ·
|
||||
[Protect](#open-protected-documents-and-respect-their-rules) ·
|
||||
[Everywhere](#runs-everywhere-stays-yours) ·
|
||||
[Get started](#get-started) ·
|
||||
[Roadmap](ROADMAP.md)
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for the milestones, progress and estimates to full parity.
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Faithful
|
||||
Real-world typography: world scripts, vertical Japanese, colour emoji, gradients, soft masks and transparency. All of it renders the way the author intended.
|
||||
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Fearless
|
||||
Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
|
||||
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Yours
|
||||
No account, no telemetry, no cloud. It works offline and opens instantly. The engine, CLI and app are all open source.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Read anything, beautifully
|
||||
|
||||
PrintCraft renders PDFs with care for the details that make a page feel right: kerning and ligatures, right-to-left and complex scripts, vertical CJK, colour emoji, shadings, blend modes, soft masks and optional content.
|
||||
|
||||
<img src="docs/images/printcraft-scripts.png" alt="A page of world scripts — Arabic, Hebrew, Devanagari, Thai, Greek, Cyrillic, Chinese, Korean, IPA, Armenian, Georgian, Tamil and vertical Japanese — rendered crisply" width="100%">
|
||||
|
||||
- **Deep zoom stays sharp.** Large pages render in tiles, so text stays crisp at any magnification.
|
||||
- **Built to survive bad files.** Every page renders in isolation and damaged documents are repaired. Across the 983-file pdf.js test corpus the result is 0 crashes.
|
||||
- **Layouts for every task:** continuous, single page, two-up, view rotation, full screen and a distraction-free Read mode.
|
||||
- **Light and dark themes**, both designed to be easy on the eyes for long sessions.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/printcraft-twoup.png" alt="Two-up reading in Read mode with the dark theme"></td>
|
||||
<td width="50%"><img src="docs/images/printcraft-dark.png" alt="The dark theme with the comments panel"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>Two-up Read mode</sub></td>
|
||||
<td align="center"><sub>Dark theme</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Find it, select it, copy it
|
||||
|
||||
Search the whole document as you type, step through matches with <kbd>⌘G</kbd>, and select text that comes out in the right reading order. That holds for columns, right-to-left runs and CJK too.
|
||||
|
||||
<img src="docs/images/printcraft-find.png" alt="Find bar showing match 10 of 16 for the word 'type', highlighted on the page" width="100%">
|
||||
|
||||
## Navigate long documents
|
||||
|
||||
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents.
|
||||
|
||||
<img src="docs/images/printcraft-bookmarks.png" alt="The bookmarks panel showing a nested outline next to a page of world scripts" width="100%">
|
||||
|
||||
---
|
||||
|
||||
## Organize pages like cards on a table
|
||||
|
||||
Open **Organize pages** to see every page at once:
|
||||
- **Select pages:** click, <kbd>⌘</kbd>-click or <kbd>⇧</kbd>-click.
|
||||
- **Change them:** rotate, delete, insert blank pages, insert pages from another file, and move them earlier or later.
|
||||
- **Undo anything:** <kbd>⌘Z</kbd>, then save.
|
||||
|
||||
<img src="docs/images/printcraft-organize.png" alt="The Organize pages grid with three pages selected and the page toolbar above" width="100%">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Undo that goes the distance.** Each change is one step in a history you can walk backwards and forwards. The Edit menu names the step ("Undo Rotate pages"), and undo still works after you save.
|
||||
|
||||
**Saves you can trust:**
|
||||
- *Incremental:* the original bytes stay byte-for-byte intact.
|
||||
- *Atomic:* the file is written to a temporary copy, then swapped in.
|
||||
- *Verified:* independently checked with qpdf.
|
||||
|
||||
Unsaved documents carry a dot on their tab, and closing or quitting asks before anything is lost. Changes are autosaved every minute. If PrintCraft ever quits unexpectedly, it offers to recover your work the next time it opens. Encrypted documents stay encrypted on disk.
|
||||
|
||||
</td>
|
||||
<td width="50%"><img src="docs/images/printcraft-split.png" alt="The Split document dialog over the organize view"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Combine and split without losing a thing
|
||||
|
||||
**Combine files** merges any number of PDFs into one. Each file gets a bookmark, with its own bookmarks nested underneath.
|
||||
|
||||
**Extract** copies the pages you select into a new document. **Split** divides a document every *n* pages, or before the pages you choose.
|
||||
|
||||
Nothing quietly disappears along the way:
|
||||
- links and named destinations are rewired to the copied pages;
|
||||
- form fields stay interactive;
|
||||
- layers keep their on/off defaults;
|
||||
- attachments come along.
|
||||
|
||||
Every page of a combined document renders pixel-identical to its source.
|
||||
|
||||
```sh
|
||||
cargo run -p printcraft -- some.pdf # desktop app
|
||||
cargo xtask demo-pdf # build dist/demo/printcraft-showcase.pdf (needs Chrome)
|
||||
cargo run -p printcraft -- dist/demo/printcraft-showcase.pdf --panel comments
|
||||
cargo run -p printcraft-cli -- edit in.pdf --rotate 1:90 --delete 3 --title "Report" --out out.pdf
|
||||
printcraft-cli combine report.pdf appendix.pdf --out combined.pdf
|
||||
printcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf
|
||||
printcraft-cli split report.pdf --every 10 --out-dir parts/
|
||||
```
|
||||
|
||||
The rules for contributors and agents are in [CLAUDE.md](CLAUDE.md).
|
||||
---
|
||||
|
||||
## The Craft family
|
||||
## Open protected documents and respect their rules
|
||||
|
||||
PrintCraft implements the PDF standard security handler completely:
|
||||
- every revision, from 40-bit RC4 to AES-256;
|
||||
- user and owner passwords, including Unicode passwords normalised with SASLprep;
|
||||
- crypt filters and attachment-only encryption.
|
||||
|
||||
Documents restricted by their author show a clear notice, and PrintCraft honours their permissions. Enter the owner password and the restrictions lift. Edits to encrypted documents are saved encrypted, under the same keys.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/printcraft-properties.png" alt="Document Properties with editable title, author, subject and keywords"></td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Document Properties** shows:
|
||||
- the document's title, author, subject and keywords, which you can edit;
|
||||
- the fonts it uses and whether each is embedded;
|
||||
- PDF version, page size, tags, fields, layers and attachments;
|
||||
- the full security picture: encryption method, which password opened it, and each permission.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Comments, forms, layers and attachments
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/printcraft-forms.png" alt="An interactive form with highlighted fields and the Fields panel listing every field and value"></td>
|
||||
<td width="50%"><img src="docs/images/printcraft-layers.png" alt="A review page with markup and a DRAFT watermark layer, and the Layers panel"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top"><b>Forms</b>: every field with its current value, field highlighting, and checkboxes, radio buttons, lists and signatures drawn the way their author designed them.</td>
|
||||
<td valign="top"><b>Layers</b>: switch optional content on and off and the page re-renders instantly. <b>Comments</b> appear as threaded conversations, and <b>attachments</b> can be opened or saved.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Every tool, one keystroke away
|
||||
|
||||
Press <kbd>⌘K</kbd> to search every tool and command, or browse the **All tools** catalogue. Tools that are still in development are marked with the milestone that will ship them.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/printcraft-palette.png" alt="The command palette searching for page tools"></td>
|
||||
<td width="50%"><img src="docs/images/printcraft-tools.png" alt="The home screen with recommended tools and the full tool catalogue"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Runs everywhere, stays yours
|
||||
|
||||
- **Native on macOS, Windows and Linux**, and **in the browser** through WebAssembly, from the same Rust codebase.
|
||||
- **Private by design.** Documents never leave your machine. There's no account, no telemetry and no cloud processing.
|
||||
- **Engine first.** Parsing, rendering and editing live in reusable library crates. The interface is one swappable layer on top.
|
||||
- **Scriptable.** The `printcraft-cli` tool covers inspecting, rendering, extracting text, editing, combining, extracting pages and splitting. Robustness sweeps run on the same engine as the app.
|
||||
|
||||
```sh
|
||||
printcraft-cli info form.pdf # structure as JSON
|
||||
printcraft-cli text paper.pdf --page 3 # reading-order text
|
||||
printcraft-cli edit in.pdf --rotate 1,2:90 --delete 5 --title "Q3" --out out.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it's built
|
||||
|
||||
PrintCraft is a Cargo workspace of focused crates, layered so the core never depends on the UI:
|
||||
|
||||
| Crate | What it does |
|
||||
|---|---|
|
||||
| `printcraft-filters` | Every PDF stream filter (Flate, LZW, ASCII85, RunLength, predictors), encode and decode, property-tested |
|
||||
| `printcraft-crypt` | The standard security handler: RC4, AES-128/256, revisions 2–6, permissions |
|
||||
| `printcraft-cos` | The PDF object layer: tolerant parsing, repair, copy-on-write edits, incremental and full writing |
|
||||
| `printcraft-organize` | Page operations, combine / extract / split, document information |
|
||||
| `printcraft-render` | Rendering, inspection and text extraction with reading order |
|
||||
| `printcraft-engine` | The façade every frontend uses: sessions, edits, undo, saving, the tool catalogue |
|
||||
| `printcraft-ui-egui` | The desktop and web interface |
|
||||
|
||||
**Quality gates.** Every change passes the same automated checks:
|
||||
- formatting, and Clippy with warnings as errors;
|
||||
- 200+ unit, property and UI tests;
|
||||
- crate-layering rules and a WebAssembly build check;
|
||||
- an asset-licence audit.
|
||||
|
||||
On top of those, two corpus sweeps run over real-world files:
|
||||
- **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes.
|
||||
- **Open, edit and save round trips:** 958 succeed.
|
||||
|
||||
The output is verified with independent tools: hayro, qpdf and poppler.
|
||||
|
||||
PrintCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
## Get started
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/printcraft
|
||||
cd printcraft
|
||||
cargo run --release -p printcraft -- some.pdf # desktop app
|
||||
cargo xtask demo-pdf # build the showcase PDF used in these screenshots
|
||||
cargo xtask screenshots # regenerate every screenshot in this README
|
||||
```
|
||||
|
||||
## What's next
|
||||
|
||||
PrintCraft is young and moving fast. **Available today:**
|
||||
- viewing, search and navigation;
|
||||
- organizing pages, combining, extracting and splitting;
|
||||
- document information;
|
||||
- opening encrypted documents, honouring their permissions, and saving them encrypted;
|
||||
- undo and safe saving;
|
||||
- autosave with crash recovery;
|
||||
- a single command registry behind menus, shortcuts and the palette.
|
||||
|
||||
**On the roadmap:**
|
||||
|
||||
| Next up | Milestone |
|
||||
|---|---|
|
||||
| Bookmark and page-label editing, page boxes | M4 |
|
||||
| Creating and editing comments, stamps, FDF/XFDF | M5 |
|
||||
| Filling and authoring forms, JavaScript | M6 |
|
||||
| Editing text and images in place, headers, watermarks | M7 |
|
||||
| Adding passwords, redaction | M8 |
|
||||
| Digital signatures (PAdES) | M9 |
|
||||
| OCR, export to Office formats, printing | M10 |
|
||||
| Optimize, preflight, PDF/A | M11 |
|
||||
| Accessibility, compare, measure | M12 |
|
||||
|
||||
The full plan, with progress and estimates, is in **[ROADMAP.md](ROADMAP.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Crafting Apps
|
||||
|
||||
Open-source, clean-room, pure-Rust creative applications. Each is native on macOS, Windows and Linux, runs on the web, and can be driven by agents.
|
||||
|
||||
|
|
@ -77,4 +309,6 @@ Open-source, clean-room, pure-Rust creative applications. Each is native on macO
|
|||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0 ([LICENSE-MIT](LICENSE-MIT), [LICENSE-APACHE](LICENSE-APACHE)). Every icon, font and image is openly licensed and listed, with its author and source, in [ATTRIBUTION.md](ATTRIBUTION.md). The policy is in [AGENTS.md](AGENTS.md), and required notices are in [NOTICE](NOTICE).
|
||||
MIT OR Apache-2.0 ([LICENSE-MIT](LICENSE-MIT), [LICENSE-APACHE](LICENSE-APACHE)). Every icon, font and image is openly licensed and listed, with its author and source, in [ATTRIBUTION.md](ATTRIBUTION.md). The policy is in [AGENTS.md](AGENTS.md), and required notices are in [NOTICE](NOTICE). Contributors and agents: read [AGENTS.md](AGENTS.md) and [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
<sub>Adobe, Acrobat, Photoshop, Illustrator, Premiere Pro and Lightroom are trademarks of Adobe Inc. PrintCraft is an independent project, not affiliated with or endorsed by Adobe.</sub>
|
||||
|
|
|
|||
18
ROADMAP.md
|
|
@ -32,23 +32,23 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
|
|||
| M | Milestone | Est. hours | Done | Remaining (h) | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 70% | 5–10 | Missing: GitHub workflow, `deny.toml`, remaining crate stubs, parity TOML |
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 40% | 70–120 | Filters done. cos parses every xref form, repairs damaged files, and writes incremental and full saves. Open, edit and save pass on 946 of 951 corpus files. Missing: crypt (R2–R6), object-stream and xref-stream output for full saves, fuzzing, ≥ 250 tests |
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 55% | 55–90 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Missing: object-stream and xref-stream output, fuzzing, ≥ 250 tests |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 65% | 30–55 | Acrobat-style shell, find, select, panels, tiles, web build. Missing: UI control channel, 60 fps test on a 500-page document, snapshot tests of every panel |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 30% | 70–125 | Done: page rotate, delete, move and insert blank; info edits; undo/redo (⌘Z/⇧⌘Z, Edit menu); save and save-as (incremental, atomic write, web download); a dot on tabs with unsaved changes; save prompt on close and quit; organize-grid multi-select; CLI `edit`. Missing: command registry, autosave and recovery, combine/split, bookmark and label editing, drag-to-reorder |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 60% | 40–70 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file;<br>- CLI `edit/combine/extract/split`.<br>Missing: bookmark and page-label editing, page boxes, CLI `run`, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 0% | 120–200 | Read-only comments panel exists |
|
||||
| M6 | Forms + JavaScript | 160–320 | 0% | 160–320 | Fields are displayed, not filled |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 0% | 250–500 | Longest pole |
|
||||
| M8 | Security + redaction | 100–180 | 0% | 100–180 | |
|
||||
| M8 | Security + redaction | 100–180 | 10% | 90–160 | Done: opening protected documents, honouring permissions, and the engine side of setting and removing passwords. Missing: Protect UI, certificate security, sanitize, redaction |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 0% | 160–280 | |
|
||||
| M10 | OCR, create, export, print | 200–350 | 0% | 200–350 | |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 0% | 200–350 | |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 0% | 200–380 | |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 0% | 60–120 | |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 0% | 120–250 | |
|
||||
| | **Total** | **2,085–3,840** | **≈ 5%** | **≈ 1,915–3,540** | |
|
||||
| | **Total** | **2,085–3,840** | **≈ 7%** | **≈ 1,870–3,465** | |
|
||||
|
||||
**Overall progress: about 5% of the effort.** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen.
|
||||
**Overall progress: about 7% of the effort.** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen.
|
||||
|
||||
## Critical path
|
||||
|
||||
|
|
@ -67,6 +67,14 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
|||
|
||||
Newest first. One line per session: the date, what moved, and the new overall percentage.
|
||||
|
||||
- **2026-09-30 (session 4):**
|
||||
- Combine, extract and split, with links, named destinations, fields, layers, attachments and nested bookmarks all carried over. Combined pages render pixel-identical to their sources.
|
||||
- Fixed a precision bug in how reals were written.
|
||||
- Encryption: a new `crypt` crate covering R2–R6. Encrypted documents can be opened, edited and saved; permissions are honoured; the Security tab is real. Checked against hayro and qpdf.
|
||||
- Autosave and crash recovery.
|
||||
- Command registry.
|
||||
- Asset policy (`AGENTS.md`, `ATTRIBUTION.toml`, `xtask assets`) and a README with 13 reproducible screenshots (`xtask screenshots`).
|
||||
- Overall ≈ 7%.
|
||||
- **2026-09-30 (session 3):**
|
||||
- New crates: `filters` (every non-image filter, 57 tests) and `cos` (object parser, xref reader, repair, incremental and full writer).
|
||||
- New `organize` crate: page operations and info edits.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
//! printcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||||
//! printcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||||
//! [--insert-blank 1] [--title T] [--author A] [--full]
|
||||
//! printcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||||
//! printcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
//! printcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
//! printcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
//! ```
|
||||
//!
|
||||
|
|
@ -28,13 +31,16 @@ fn main() -> ExitCode {
|
|||
Some("render") => render(&args[1..]),
|
||||
Some("text") => text(&args[1..]),
|
||||
Some("edit") => edit(&args[1..]),
|
||||
Some("combine") => combine(&args[1..]),
|
||||
Some("extract") => extract(&args[1..]),
|
||||
Some("split") => split(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("check-one") => check_one(&args[1..]),
|
||||
Some("--version") => {
|
||||
println!("printcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("usage: printcraft-cli <info|render|text|edit|check> … (see source header for options)".into()),
|
||||
_ => Err("usage: printcraft-cli <info|render|text|edit|combine|extract|split|check> … (see source header for options)".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
|
|
@ -155,6 +161,51 @@ fn edit(args: &[String]) -> Result<(), String> {
|
|||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
fn combine(args: &[String]) -> Result<(), String> {
|
||||
let out = flag(args, "--out").ok_or("combine: missing --out")?;
|
||||
let inputs = positional(args);
|
||||
if inputs.len() < 2 {
|
||||
return Err("combine: give at least two input files".into());
|
||||
}
|
||||
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
|
||||
let bytes = printcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||||
}
|
||||
|
||||
fn extract(args: &[String]) -> Result<(), String> {
|
||||
let path = *positional(args).first().ok_or("extract: missing file")?;
|
||||
let out = flag(args, "--out").ok_or("extract: missing --out")?;
|
||||
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
|
||||
let mut session = printcraft_engine::Session::new();
|
||||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
|
||||
}
|
||||
|
||||
fn split(args: &[String]) -> Result<(), String> {
|
||||
use printcraft_engine::SplitBy;
|
||||
let path = *positional(args).first().ok_or("split: missing file")?;
|
||||
let by = match (flag(args, "--every"), flag(args, "--before")) {
|
||||
(Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
|
||||
(None, Some(list)) => SplitBy::Before(page_list(list)?),
|
||||
_ => return Err("split: give either --every N or --before PAGES".into()),
|
||||
};
|
||||
let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
|
||||
let mut session = printcraft_engine::Session::new();
|
||||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let stem = file_stem(path);
|
||||
for (a, b, bytes) in session.split(id, &by).map_err(|e| e.to_string())? {
|
||||
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
|
||||
std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
|
||||
println!("{}", name.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), String> {
|
||||
let path = *positional(args).first().ok_or("render: missing file")?;
|
||||
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ fn main() -> eframe::Result {
|
|||
app.restore(&json);
|
||||
}
|
||||
app.integrated_titlebar = integrated;
|
||||
// Autosave unsaved changes; offer to recover documents a crashed session left behind.
|
||||
if let Some(dir) = printcraft_ui_egui::RecoveryStore::default_dir() {
|
||||
app.enable_recovery(printcraft_ui_egui::RecoveryStore::new(dir));
|
||||
}
|
||||
for f in files {
|
||||
app.open_path(&f);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ license.workspace = true
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
printcraft-crypt = { workspace = true }
|
||||
printcraft-filters = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
hayro-syntax = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
//! Print how a file was opened: revisions, repair notes, trailer. `cargo run -p printcraft-cos --example cosinfo f.pdf`
|
||||
//! Print how a file was opened: revisions, repair notes, trailer, security and the catalog.
|
||||
//! `cargo run -p printcraft-cos --example cosinfo f.pdf [password] [object-number…]`
|
||||
fn show(o: &printcraft_cos::Object) -> String {
|
||||
let mut t = Vec::new();
|
||||
printcraft_cos::serialize(o, &mut t);
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
s.chars().take(400).collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: cosinfo <file.pdf>");
|
||||
let mut args = std::env::args().skip(1);
|
||||
let path = args.next().expect("usage: cosinfo <file.pdf> [password] [obj…]");
|
||||
let password = args.next().filter(|p| !p.is_empty());
|
||||
let objs: Vec<u32> = args.filter_map(|a| a.parse().ok()).collect();
|
||||
let bytes = std::sync::Arc::new(std::fs::read(&path).expect("readable"));
|
||||
match printcraft_cos::Document::open(bytes) {
|
||||
match printcraft_cos::Document::open_with_password(bytes, password.as_deref()) {
|
||||
Ok(doc) => {
|
||||
println!("version {} revisions {:?}", doc.version(), doc.revisions());
|
||||
for r in doc.repair_log() {
|
||||
println!("repair: {r}");
|
||||
}
|
||||
let mut t = Vec::new();
|
||||
printcraft_cos::serialize(&printcraft_cos::Object::Dict(doc.trailer().clone()), &mut t);
|
||||
println!("trailer {}", String::from_utf8_lossy(&t));
|
||||
println!("trailer {}", show(&printcraft_cos::Object::Dict(doc.trailer().clone())));
|
||||
if let Some(s) = doc.security() {
|
||||
println!("security: auth {:?}, key {} bytes, {:?}", s.auth(), s.file_key().len(), s.permissions());
|
||||
}
|
||||
if let Some(r) = doc.root() {
|
||||
println!("catalog {}", show(&doc.get(r)));
|
||||
}
|
||||
for n in objs {
|
||||
match doc.try_get(n) {
|
||||
Ok(o) => println!("{n} 0 obj {}", show(&o)),
|
||||
Err(e) => println!("{n} 0 obj error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("error: {e}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
//! Encrypt a PDF: `cargo run -p printcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER`
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") };
|
||||
let algorithm = match alg.as_str() {
|
||||
"rc4-40" => printcraft_cos::Algorithm::Rc4_40,
|
||||
"rc4-128" => printcraft_cos::Algorithm::Rc4_128,
|
||||
"aes-128" => printcraft_cos::Algorithm::Aes128,
|
||||
_ => printcraft_cos::Algorithm::Aes256,
|
||||
};
|
||||
let mut doc = printcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(input).unwrap())).unwrap();
|
||||
doc.set_encryption(&printcraft_cos::NewEncryption {
|
||||
algorithm,
|
||||
user_password: user,
|
||||
owner_password: owner,
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(output, printcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
|
|
@ -83,6 +83,15 @@ pub struct Document {
|
|||
/// Position of the `%PDF-` header (some files have junk before it).
|
||||
header_offset: usize,
|
||||
version: String,
|
||||
/// The authenticated security handler of an encrypted document.
|
||||
security: Option<Arc<printcraft_crypt::SecurityHandler>>,
|
||||
/// Object number of the `/Encrypt` dictionary (never encrypted itself).
|
||||
encrypt_num: Option<u32>,
|
||||
/// Encryption was added, changed or removed since opening: only a full save can apply it.
|
||||
encryption_changed: bool,
|
||||
/// The handler and `/Encrypt` object number that saves use, when encryption changed.
|
||||
out_security: Option<Arc<printcraft_crypt::SecurityHandler>>,
|
||||
out_encrypt_num: Option<u32>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Document {
|
||||
|
|
@ -92,8 +101,49 @@ impl std::fmt::Debug for Document {
|
|||
}
|
||||
|
||||
impl Document {
|
||||
/// A new document with an empty page tree (for split / extract / combine). It has no
|
||||
/// original bytes, so saving always writes a full file.
|
||||
pub fn new_empty() -> Self {
|
||||
let mut doc = Document {
|
||||
data: Arc::new(Vec::new()),
|
||||
entries: Arc::new(BTreeMap::new()),
|
||||
trailer: Dict::new(),
|
||||
revisions: Arc::new(Vec::new()),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
header_offset: 0,
|
||||
version: "1.7".into(),
|
||||
security: None,
|
||||
encrypt_num: None,
|
||||
encryption_changed: false,
|
||||
out_security: None,
|
||||
out_encrypt_num: None,
|
||||
};
|
||||
let mut pages = Dict::new();
|
||||
pages.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
pages.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
pages.set(b"Count".to_vec(), Object::Int(0));
|
||||
let pages = doc.add(pages);
|
||||
let mut catalog = Dict::new();
|
||||
catalog.set(b"Type".to_vec(), Object::name("Catalog"));
|
||||
catalog.set(b"Pages".to_vec(), Object::Ref(pages));
|
||||
let root = doc.add(catalog);
|
||||
doc.trailer.set(b"Root".to_vec(), Object::Ref(root));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`.
|
||||
/// Encrypted documents open only if their user password is empty; see `open_with_password`.
|
||||
pub fn open(data: Arc<Vec<u8>>) -> Result<Self, CosError> {
|
||||
Self::open_with_password(data, None)
|
||||
}
|
||||
|
||||
/// Parse a document, authenticating encrypted ones with `password` (owner or user; `None`
|
||||
/// tries the empty password). Fails with `NeedsPassword` / `WrongPassword` as appropriate.
|
||||
pub fn open_with_password(data: Arc<Vec<u8>>, password: Option<&str>) -> Result<Self, CosError> {
|
||||
// Viewers accept files whose header is missing or damaged as long as the body looks
|
||||
// like PDF; so do we (a note goes to the repair log).
|
||||
let header = find(&data, b"%PDF-", 0, 1024);
|
||||
|
|
@ -118,6 +168,11 @@ impl Document {
|
|||
next_num: 1,
|
||||
header_offset,
|
||||
version,
|
||||
security: None,
|
||||
encrypt_num: None,
|
||||
encryption_changed: false,
|
||||
out_security: None,
|
||||
out_encrypt_num: None,
|
||||
};
|
||||
let mut log = Vec::new();
|
||||
if headerless {
|
||||
|
|
@ -143,8 +198,8 @@ impl Document {
|
|||
doc.reconstruct(&mut log)?;
|
||||
}
|
||||
doc.repair_log = Arc::new(log);
|
||||
if doc.trailer.contains(b"Encrypt") {
|
||||
return Err(CosError::Encrypted);
|
||||
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
|
||||
doc.authenticate(&enc, password)?;
|
||||
}
|
||||
let max = doc.entries.keys().next_back().copied().unwrap_or(0);
|
||||
let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32;
|
||||
|
|
@ -152,6 +207,136 @@ impl Document {
|
|||
Ok(doc)
|
||||
}
|
||||
|
||||
fn authenticate(&mut self, enc: &Object, password: Option<&str>) -> Result<(), CosError> {
|
||||
let (num, dict) = match enc {
|
||||
Object::Ref(r) => (Some(r.num), self.get(*r).as_dict().cloned()),
|
||||
Object::Dict(d) => (None, Some(d.clone())),
|
||||
_ => (None, None),
|
||||
};
|
||||
let Some(dict) = dict else {
|
||||
// A dangling /Encrypt: viewers treat the file as unencrypted, and so do we.
|
||||
let mut log = self.repair_log.as_ref().clone();
|
||||
log.push("the trailer names an /Encrypt dictionary that does not exist; reading the file as unencrypted".into());
|
||||
self.repair_log = Arc::new(log);
|
||||
self.trailer.remove(b"Encrypt");
|
||||
return Ok(());
|
||||
};
|
||||
let params = crate::security::encrypt_dict(self, &dict);
|
||||
let id0 = match self.trailer.get(b"ID") {
|
||||
Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let handler = printcraft_crypt::SecurityHandler::open(params, &id0, password).map_err(|e| match e {
|
||||
printcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
printcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?;
|
||||
self.security = Some(Arc::new(handler));
|
||||
self.encrypt_num = num;
|
||||
// Objects read while locating the catalog were read before decryption was possible.
|
||||
self.cache = Arc::default();
|
||||
self.objstms = Arc::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The security handler, when the document is encrypted.
|
||||
pub fn security(&self) -> Option<&printcraft_crypt::SecurityHandler> {
|
||||
self.security.as_deref()
|
||||
}
|
||||
|
||||
/// What the opening password allows (`None` for unencrypted documents: everything).
|
||||
pub fn permissions(&self) -> Option<printcraft_crypt::Permissions> {
|
||||
self.security().map(|s| s.permissions())
|
||||
}
|
||||
|
||||
pub(crate) fn encryption_changed(&self) -> bool {
|
||||
self.encryption_changed
|
||||
}
|
||||
|
||||
/// The handler used to write: the new one after `set_encryption` / `remove_encryption`,
|
||||
/// otherwise the one the document was opened with.
|
||||
pub(crate) fn output_security(&self) -> (Option<&printcraft_crypt::SecurityHandler>, Option<u32>) {
|
||||
if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) }
|
||||
}
|
||||
|
||||
/// Protect the document with a password (§7.6.4). Takes effect on the next save, which is
|
||||
/// always a full rewrite. Returns the handler (authenticated as owner).
|
||||
pub fn set_encryption(&mut self, params: &printcraft_crypt::NewEncryption) -> Result<(), CosError> {
|
||||
// The file identifier is part of the key; make sure it exists and keep it.
|
||||
let id0 = match self.trailer.get(b"ID") {
|
||||
Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(),
|
||||
_ => {
|
||||
let mut id = generated_id(¶ms.seed);
|
||||
id.truncate(16);
|
||||
let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true });
|
||||
self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
|
||||
id
|
||||
}
|
||||
};
|
||||
let h = printcraft_crypt::create(params, &id0).map_err(|e| CosError::Security(e.to_string()))?;
|
||||
let d = h.dict();
|
||||
let s = |b: &[u8]| Object::String(crate::PdfString { bytes: b.to_vec(), hex: true });
|
||||
let mut e = Dict::new();
|
||||
e.set(b"Filter".to_vec(), Object::name("Standard"));
|
||||
e.set(b"V".to_vec(), Object::Int(d.v));
|
||||
e.set(b"R".to_vec(), Object::Int(d.r));
|
||||
e.set(b"Length".to_vec(), Object::Int(d.length_bits));
|
||||
e.set(b"O".to_vec(), s(&d.o));
|
||||
e.set(b"U".to_vec(), s(&d.u));
|
||||
e.set(b"P".to_vec(), Object::Int(i64::from(d.p)));
|
||||
if d.v >= 4 {
|
||||
let mut cf = Dict::new();
|
||||
let mut std_cf = Dict::new();
|
||||
std_cf.set(b"Type".to_vec(), Object::name("CryptFilter"));
|
||||
let (cfm, len) = if d.v >= 5 { ("AESV3", 32) } else { ("AESV2", 16) };
|
||||
std_cf.set(b"CFM".to_vec(), Object::name(cfm));
|
||||
std_cf.set(b"Length".to_vec(), Object::Int(len));
|
||||
std_cf.set(b"AuthEvent".to_vec(), Object::name("DocOpen"));
|
||||
cf.set(b"StdCF".to_vec(), Object::Dict(std_cf));
|
||||
e.set(b"CF".to_vec(), Object::Dict(cf));
|
||||
e.set(b"StmF".to_vec(), Object::name("StdCF"));
|
||||
e.set(b"StrF".to_vec(), Object::name("StdCF"));
|
||||
if !d.encrypt_metadata {
|
||||
e.set(b"EncryptMetadata".to_vec(), Object::Bool(false));
|
||||
}
|
||||
}
|
||||
if d.v >= 5 {
|
||||
e.set(b"OE".to_vec(), s(&d.oe));
|
||||
e.set(b"UE".to_vec(), s(&d.ue));
|
||||
e.set(b"Perms".to_vec(), s(&d.perms));
|
||||
}
|
||||
if let Some(Object::Ref(old)) = self.trailer.get(b"Encrypt").cloned() {
|
||||
self.free(old);
|
||||
}
|
||||
let r = self.add(e);
|
||||
self.trailer.set(b"Encrypt".to_vec(), Object::Ref(r));
|
||||
// Objects are still read with the original handler; saves use the new one.
|
||||
self.out_security = Some(Arc::new(h));
|
||||
self.out_encrypt_num = Some(r.num);
|
||||
self.encryption_changed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove password protection (requires the document to be open — any authenticated
|
||||
/// password; callers should require the owner password, §7.6.4). Applied by a full save.
|
||||
pub fn remove_encryption(&mut self) {
|
||||
if let Some(Object::Ref(r)) = self.trailer.get(b"Encrypt").cloned() {
|
||||
self.free(r);
|
||||
}
|
||||
self.trailer.remove(b"Encrypt");
|
||||
self.out_security = None;
|
||||
self.out_encrypt_num = None;
|
||||
self.encryption_changed = true;
|
||||
}
|
||||
|
||||
/// Decrypt a freshly parsed indirect object if the document is encrypted.
|
||||
fn decrypted(&self, id: ObjRef, o: Object) -> Object {
|
||||
match &self.security {
|
||||
Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true),
|
||||
_ => o,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> &Arc<Vec<u8>> {
|
||||
&self.data
|
||||
}
|
||||
|
|
@ -239,11 +424,11 @@ impl Document {
|
|||
let resolve = |r: ObjRef| self.try_get(r.num).ok().and_then(|o| o.as_int());
|
||||
let off = *offset as usize;
|
||||
match parse_indirect(&self.data, off, &resolve) {
|
||||
Ok((id, o)) if id.num == num => Ok(o),
|
||||
Ok((id, o)) if id.num == num => Ok(self.decrypted(id, o)),
|
||||
// Offsets relative to a shifted header, or simply wrong: try both fixes.
|
||||
_ => match parse_indirect(&self.data, off + self.header_offset, &resolve) {
|
||||
Ok((id, o)) if id.num == num => Ok(o),
|
||||
_ => self.scan_for(num).ok_or(CosError::MissingObject(num)),
|
||||
Ok((id, o)) if id.num == num => Ok(self.decrypted(id, o)),
|
||||
_ => self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -282,7 +467,7 @@ impl Document {
|
|||
}
|
||||
|
||||
/// Last-resort lookup: scan the file for `num G obj`.
|
||||
fn scan_for(&self, num: u32) -> Option<Object> {
|
||||
fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> {
|
||||
let needle = format!("{num} ");
|
||||
let data = &self.data;
|
||||
let mut found = None;
|
||||
|
|
@ -295,7 +480,7 @@ impl Document {
|
|||
if let Ok((id, o)) = parse_indirect(data, p, &|_| None)
|
||||
&& id.num == num
|
||||
{
|
||||
found = Some(o); // keep the last (newest) definition
|
||||
found = Some((id, o)); // keep the last (newest) definition
|
||||
}
|
||||
}
|
||||
found
|
||||
|
|
@ -632,6 +817,18 @@ fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
|||
(from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
|
||||
}
|
||||
|
||||
/// A file identifier derived from entropy supplied by the caller.
|
||||
fn generated_id(seed: &[u8; 32]) -> Vec<u8> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut out = Vec::new();
|
||||
for i in 0..2u8 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(seed, i, b"printcraft id").hash(&mut h);
|
||||
out.extend_from_slice(&h.finish().to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@
|
|||
mod document;
|
||||
mod object;
|
||||
mod parser;
|
||||
mod security;
|
||||
mod writer;
|
||||
|
||||
pub use document::{Document, Revision, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
|
||||
pub use parser::{Lexer, parse_indirect};
|
||||
pub use printcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler};
|
||||
pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental};
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
||||
|
|
@ -31,8 +33,12 @@ pub enum CosError {
|
|||
NotADictionary(ObjRef),
|
||||
#[error("stream data could not be decoded: {0}")]
|
||||
Filter(String),
|
||||
#[error("the document is encrypted; editing encrypted documents arrives with M1.6")]
|
||||
Encrypted,
|
||||
#[error("the document is protected by a password")]
|
||||
NeedsPassword,
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
#[error("unsupported security: {0}")]
|
||||
Security(String),
|
||||
#[error("internal lock poisoned")]
|
||||
Poisoned,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ impl Stream {
|
|||
names
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
// `/Crypt` is applied by the security handler when the object is loaded (§7.4.10).
|
||||
.filter(|(_, n)| *n != b"Crypt")
|
||||
.map(|(i, n)| {
|
||||
let p = parms.get(i).copied().flatten();
|
||||
let get = |k: &[u8], d: i64| p.and_then(|p| p.int(k)).unwrap_or(d);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
//! Bridging the COS object model and `printcraft-crypt`: reading `/Encrypt` dictionaries and
|
||||
//! decrypting / encrypting the strings and streams of an object (§7.6.2).
|
||||
//!
|
||||
//! Not encrypted, per the spec: the `/Encrypt` dictionary itself, cross-reference streams, the
|
||||
//! trailer `/ID`, objects inside object streams (the object stream is encrypted as a whole), and
|
||||
//! metadata streams when `/EncryptMetadata` is false. Streams naming a crypt filter (`/Crypt` in
|
||||
//! `/Filter`) use that filter; embedded files use `/EFF`.
|
||||
|
||||
use printcraft_crypt::{EncryptDict, Method, SecurityHandler, StreamKind};
|
||||
|
||||
use crate::{Dict, Document, Object, PdfString, Stream};
|
||||
|
||||
fn bytes_of(doc: &Document, d: &Dict, key: &[u8]) -> Vec<u8> {
|
||||
d.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_string().map(|s| s.bytes.clone())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the handler parameters from an `/Encrypt` dictionary.
|
||||
pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
|
||||
let int = |k: &[u8], default: i64| d.get(k).map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(default);
|
||||
let name = |k: &[u8]| d.get(k).and_then(|v| v.as_name()).map(|n| n.to_vec()).unwrap_or_else(|| b"Identity".to_vec());
|
||||
let mut crypt_filters = Vec::new();
|
||||
if let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) {
|
||||
for (k, v) in cf.iter() {
|
||||
let Some(f) = doc.resolve(v).as_dict().cloned() else { continue };
|
||||
let method = match f.name(b"CFM") {
|
||||
Some(b"V2") => Method::Rc4,
|
||||
Some(b"AESV2") => Method::Aes128,
|
||||
Some(b"AESV3") => Method::Aes256,
|
||||
_ => Method::Identity,
|
||||
};
|
||||
crypt_filters.push((k.clone(), method));
|
||||
}
|
||||
}
|
||||
let v = int(b"V", 0);
|
||||
// V4: the key length comes from the standard crypt filter's /Length (bytes, or bits in
|
||||
// some files); /Length on the dictionary is often a stale 40.
|
||||
let cf_len = d
|
||||
.get(b"CF")
|
||||
.map(|c| doc.resolve(c))
|
||||
.and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f))))
|
||||
.and_then(|f| f.as_dict().and_then(|f| f.int(b"Length")));
|
||||
let length_bits = match (v, cf_len) {
|
||||
(5.., _) => 256,
|
||||
(4, Some(n)) if n <= 32 => n * 8,
|
||||
(4, Some(n)) => n,
|
||||
(4, None) => 128,
|
||||
_ => int(b"Length", 40),
|
||||
};
|
||||
EncryptDict {
|
||||
filter: d.name(b"Filter").map(|n| n.to_vec()).unwrap_or_default(),
|
||||
v,
|
||||
r: int(b"R", 0),
|
||||
length_bits,
|
||||
o: bytes_of(doc, d, b"O"),
|
||||
u: bytes_of(doc, d, b"U"),
|
||||
oe: bytes_of(doc, d, b"OE"),
|
||||
ue: bytes_of(doc, d, b"UE"),
|
||||
perms: bytes_of(doc, d, b"Perms"),
|
||||
p: int(b"P", -1) as i32,
|
||||
encrypt_metadata: !matches!(d.get(b"EncryptMetadata"), Some(Object::Bool(false))),
|
||||
crypt_filters,
|
||||
stm_f: name(b"StmF"),
|
||||
str_f: name(b"StrF"),
|
||||
ef_f: d.get(b"EFF").and_then(|v| v.as_name()).map(|n| n.to_vec()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The crypt filter a stream names in its `/Filter` chain (`/Crypt` + `/DecodeParms /Name`).
|
||||
fn named_crypt_filter(dict: &Dict) -> Option<Vec<u8>> {
|
||||
let names: Vec<&[u8]> = match dict.get(b"Filter") {
|
||||
Some(Object::Name(n)) => vec![n],
|
||||
Some(Object::Array(a)) => a.iter().filter_map(Object::as_name).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
let i = names.iter().position(|n| *n == b"Crypt")?;
|
||||
let parms = match dict.get(b"DecodeParms") {
|
||||
Some(Object::Dict(d)) if i == 0 => Some(d.clone()),
|
||||
Some(Object::Array(a)) => a.get(i).and_then(|p| p.as_dict().cloned()),
|
||||
_ => None,
|
||||
};
|
||||
Some(parms.and_then(|p| p.name(b"Name").map(|n| n.to_vec())).unwrap_or_else(|| b"Identity".to_vec()))
|
||||
}
|
||||
|
||||
/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object.
|
||||
pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object {
|
||||
let strings = |s: &PdfString| {
|
||||
let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) };
|
||||
PdfString { bytes, hex: s.hex || !decrypt }
|
||||
};
|
||||
walk(o, &strings, h, num, generation, decrypt)
|
||||
}
|
||||
|
||||
fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object {
|
||||
match o {
|
||||
Object::String(s) => Object::String(strings(s)),
|
||||
Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()),
|
||||
Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()),
|
||||
Object::Stream(s) => {
|
||||
let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect();
|
||||
let ty = s.dict.name(b"Type");
|
||||
if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
|
||||
return Object::Stream(Stream { dict, raw: s.raw.clone() });
|
||||
}
|
||||
let named = named_crypt_filter(&s.dict);
|
||||
let kind = match (&named, ty) {
|
||||
(Some(n), _) => StreamKind::Named(n),
|
||||
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
|
||||
_ => StreamKind::Normal,
|
||||
};
|
||||
let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) };
|
||||
Object::Stream(Stream { dict, raw: std::sync::Arc::new(raw) })
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -51,9 +51,10 @@ fn write_real(r: f64, out: &mut Vec<u8>) {
|
|||
let _ = write!(out, "{}", r as i64);
|
||||
return;
|
||||
}
|
||||
// Plain decimal notation (no exponents, §7.3.3), trimmed.
|
||||
let s = format!("{r:.6}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
// Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the
|
||||
// shortest string that parses back to the same value, never in exponent form, so values
|
||||
// round-trip exactly (rounding to a fixed number of places visibly shifted shading colours).
|
||||
let s = format!("{r}");
|
||||
out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() });
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +120,15 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
|
|||
out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn prepared(doc: &Document, source_num: u32, num: u32, generation: u16, o: &Object) -> Object {
|
||||
match doc.output_security() {
|
||||
(Some(h), encrypt_num) if Some(source_num) != encrypt_num => crate::security::transform(h, o, num, generation, false),
|
||||
_ => o.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_indirect(num: u32, generation: u16, o: &Object, out: &mut Vec<u8>) {
|
||||
let _ = writeln!(out, "{num} {generation} obj");
|
||||
serialize(o, out);
|
||||
|
|
@ -146,7 +156,9 @@ impl Default for SaveOptions {
|
|||
/// incrementally — other readers would follow the broken chain — so it is rewritten in full,
|
||||
/// which also repairs the file (what users expect after "the file was damaged and repaired").
|
||||
pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosError> {
|
||||
if doc.revisions().is_empty() {
|
||||
// Reconstructed files have no chain to append to; added or removed encryption must
|
||||
// rewrite every object. Both need a full save.
|
||||
if doc.revisions().is_empty() || doc.encryption_changed() {
|
||||
return write_full(doc, opts);
|
||||
}
|
||||
let mut doc = doc.clone();
|
||||
|
|
@ -162,7 +174,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
|||
match obj {
|
||||
Some(o) => {
|
||||
offsets.insert(num, (out.len() as u64, generation, true));
|
||||
write_indirect(num, generation, &o, &mut out);
|
||||
write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out);
|
||||
}
|
||||
None => {
|
||||
offsets.insert(num, (0, generation, false));
|
||||
|
|
@ -172,7 +184,9 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
|||
let prev = doc.revisions().last().map(|r| r.xref_offset);
|
||||
let as_stream = doc.revisions().last().is_some_and(|r| r.is_stream);
|
||||
let mut trailer = doc.trailer().clone();
|
||||
ensure_id(&mut trailer, opts, &original);
|
||||
if doc.output_security().0.is_none() {
|
||||
ensure_id(&mut trailer, opts, &original); // an encrypted file's ID is part of its key
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
|
||||
}
|
||||
|
|
@ -200,7 +214,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
|||
let mut order: Vec<ObjRef> = Vec::new();
|
||||
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"] {
|
||||
for key in [&b"Root"[..], b"Info", b"Encrypt"] {
|
||||
if let Some(r) = trailer_in.reference(key) {
|
||||
queue.push_back(r);
|
||||
}
|
||||
|
|
@ -227,19 +241,24 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
|||
let num = (i + 1) as u32;
|
||||
let o = renumber(&doc.get(*r), &map);
|
||||
offsets.insert(num, (out.len() as u64, 0, true));
|
||||
write_indirect(num, 0, &o, &mut out);
|
||||
write_indirect(num, 0, &prepared(&doc, r.num, num, 0, &o), &mut out);
|
||||
}
|
||||
let mut trailer = Dict::new();
|
||||
trailer.set(b"Size".to_vec(), Object::Int(order.len() as i64 + 1));
|
||||
for key in [&b"Root"[..], b"Info"] {
|
||||
for key in [&b"Root"[..], b"Info", b"Encrypt"] {
|
||||
if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) {
|
||||
trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0)));
|
||||
}
|
||||
}
|
||||
if let Some(Object::Dict(d)) = trailer_in.get(b"Encrypt") {
|
||||
trailer.set(b"Encrypt".to_vec(), Object::Dict(d.clone()));
|
||||
}
|
||||
if let Some(id) = trailer_in.get(b"ID") {
|
||||
trailer.set(b"ID".to_vec(), id.clone());
|
||||
}
|
||||
ensure_id(&mut trailer, opts, doc.bytes());
|
||||
if doc.output_security().0.is_none() {
|
||||
ensure_id(&mut trailer, opts, doc.bytes());
|
||||
}
|
||||
let xref_at = out.len();
|
||||
write_xref_table(&mut out, &offsets, true);
|
||||
out.extend_from_slice(b"trailer\n");
|
||||
|
|
@ -384,3 +403,49 @@ pub fn pdf_date(unix_secs: i64) -> String {
|
|||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
use crate::Lexer;
|
||||
|
||||
fn roundtrip(o: &Object) -> Object {
|
||||
let mut out = Vec::new();
|
||||
serialize(o, &mut out);
|
||||
Lexer::new(&out, 0).object().expect("parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reals_keep_full_precision_without_exponents() {
|
||||
for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] {
|
||||
let mut out = Vec::new();
|
||||
serialize(&Object::Real(r), &mut out);
|
||||
assert!(!out.contains(&b'e') && !out.contains(&b'E'), "{}", String::from_utf8_lossy(&out));
|
||||
assert_eq!(roundtrip(&Object::Real(r)), Object::Real(r));
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn any_finite_real_round_trips(r in proptest::num::f64::NORMAL | proptest::num::f64::SUBNORMAL | proptest::num::f64::ZERO) {
|
||||
prop_assume!(r.abs() < 1e15 || r.fract() == 0.0);
|
||||
let back = roundtrip(&Object::Real(r));
|
||||
match back {
|
||||
Object::Real(b) => prop_assert_eq!(b, r),
|
||||
Object::Int(i) => prop_assert_eq!(i as f64, r),
|
||||
other => prop_assert!(false, "unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_names_round_trip(bytes in proptest::collection::vec(any::<u8>(), 0..64)) {
|
||||
let s = Object::String(PdfString { bytes: bytes.clone(), hex: false });
|
||||
prop_assert_eq!(roundtrip(&s).as_string().map(|s| s.bytes.clone()), Some(bytes.clone()));
|
||||
if !bytes.is_empty() && !bytes.contains(&0) {
|
||||
prop_assert_eq!(roundtrip(&Object::Name(bytes.clone())), Object::Name(bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn corpus_parity() {
|
|||
let result = std::panic::catch_unwind(|| -> Result<(), String> {
|
||||
let mut doc = match Document::open(bytes.clone()) {
|
||||
Ok(d) => d,
|
||||
Err(printcraft_cos::CosError::Encrypted) => return Err("encrypted".into()),
|
||||
Err(printcraft_cos::CosError::NeedsPassword) => return Err("encrypted".into()),
|
||||
Err(e) => return Err(format!("open: {e}")),
|
||||
};
|
||||
// hayro may repair a broken /Count; compare against the leaf walk count loosely.
|
||||
|
|
@ -99,3 +99,52 @@ fn corpus_parity() {
|
|||
}
|
||||
assert!(failures.iter().all(|f| !f.ends_with("PANIC")), "no panics");
|
||||
}
|
||||
|
||||
/// Password-protected corpus files (passwords from pdf.js's test manifest): the right password
|
||||
/// opens them and the edit/save round trip still works; no or a wrong password is reported.
|
||||
#[test]
|
||||
#[ignore = "needs corpus/; run with --ignored"]
|
||||
fn corpus_passwords() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs/test");
|
||||
let Ok(manifest) = std::fs::read_to_string(dir.join("test_manifest.json")) else {
|
||||
eprintln!("corpus not present");
|
||||
return;
|
||||
};
|
||||
let entries: Vec<serde_json::Value> = serde_json::from_str(&manifest).expect("manifest is JSON");
|
||||
let cases: Vec<(String, String)> =
|
||||
entries.iter().filter_map(|e| Some((e.get("file")?.as_str()?.to_string(), e.get("password")?.as_str()?.to_string()))).collect();
|
||||
assert!(cases.len() >= 5, "manifest parsed ({} cases)", cases.len());
|
||||
let mut failures = Vec::new();
|
||||
for (file, pw) in &cases {
|
||||
let Ok(bytes) = std::fs::read(dir.join(file)) else { continue };
|
||||
let bytes = Arc::new(bytes);
|
||||
if !matches!(Document::open(bytes.clone()), Err(printcraft_cos::CosError::NeedsPassword)) {
|
||||
failures.push(format!("{file}: opened without a password"));
|
||||
}
|
||||
if !matches!(Document::open_with_password(bytes.clone(), Some("definitely wrong")), Err(printcraft_cos::CosError::WrongPassword)) {
|
||||
failures.push(format!("{file}: wrong password not rejected"));
|
||||
}
|
||||
match Document::open_with_password(bytes.clone(), Some(pw)) {
|
||||
Ok(mut doc) => {
|
||||
let pages = page_count(&doc);
|
||||
if pages.unwrap_or(0) == 0 {
|
||||
failures.push(format!("{file}: no pages after decryption"));
|
||||
continue;
|
||||
}
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |d| d.set(b"PrintCraftTest".to_vec(), Object::Bool(true))).unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
match Document::open_with_password(Arc::new(saved.clone()), Some(pw)) {
|
||||
Ok(again) if page_count(&again) == pages => {}
|
||||
other => failures.push(format!("{file}: reopen after save failed ({:?})", other.err())),
|
||||
}
|
||||
}
|
||||
Err(e) => failures.push(format!("{file}: {e}")),
|
||||
}
|
||||
}
|
||||
eprintln!("password corpus: {} cases, {} failures", cases.len(), failures.len());
|
||||
for f in &failures {
|
||||
eprintln!(" {f}");
|
||||
}
|
||||
assert!(failures.is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
//! Encryption end to end: protect a document with every algorithm, save it, and read it back
|
||||
//! with our reader and with an independent one (hayro-syntax's decryption). Also editing
|
||||
//! encrypted documents incrementally, changing and removing passwords, and permissions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use printcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental};
|
||||
|
||||
const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET";
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
"<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\n<x:xmpmeta>meta</x:xmpmeta>\nendstream".replace("22", "26"),
|
||||
"<< /Title (Quarterly numbers) >>".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 o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 7 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn protect(alg: Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: user,
|
||||
owner_password: owner,
|
||||
permissions,
|
||||
encrypt_metadata: true,
|
||||
seed: [9; 32],
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn content(doc: &Document) -> String {
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap();
|
||||
let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap();
|
||||
match doc.resolve(page.get(b"Contents").unwrap()).as_ref() {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).trim().to_string(),
|
||||
_ => panic!("no content stream"),
|
||||
}
|
||||
}
|
||||
|
||||
fn title(doc: &Document) -> Option<String> {
|
||||
let info = doc.resolve(doc.trailer().get(b"Info")?);
|
||||
info.as_dict()?.get(b"Title").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text()))
|
||||
}
|
||||
|
||||
const ALL: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
|
||||
|
||||
#[test]
|
||||
fn every_algorithm_round_trips_and_hides_the_content() {
|
||||
for alg in ALL {
|
||||
let bytes = protect(alg, "user", "owner", -1);
|
||||
let raw = String::from_utf8_lossy(&bytes);
|
||||
assert!(!raw.contains("Secret page") && !raw.contains("Quarterly"), "{alg:?}: plaintext leaked");
|
||||
let bytes = Arc::new(bytes);
|
||||
assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}");
|
||||
assert!(matches!(Document::open_with_password(bytes.clone(), Some("guess")), Err(CosError::WrongPassword)), "{alg:?}");
|
||||
let user = Document::open_with_password(bytes.clone(), Some("user")).unwrap();
|
||||
assert_eq!(user.security().unwrap().auth(), Auth::User);
|
||||
assert_eq!(content(&user), SECRET, "{alg:?}");
|
||||
assert_eq!(title(&user).as_deref(), Some("Quarterly numbers"), "{alg:?}");
|
||||
let owner = Document::open_with_password(bytes.clone(), Some("owner")).unwrap();
|
||||
assert_eq!(owner.security().unwrap().auth(), Auth::Owner);
|
||||
// Independent check: hayro decrypts our output. hayro-syntax 0.7 has no owner-password
|
||||
// authentication for R2–R4 (Algorithm 7), so for those qpdf is the owner-password oracle
|
||||
// (`qpdf_agrees`); hayro checks every user password and R6 owner passwords.
|
||||
for pw in if alg == Algorithm::Aes256 { &["user", "owner"][..] } else { &["user"][..] } {
|
||||
let hay = hayro_syntax::Pdf::new_with_password(bytes.as_ref().clone(), pw).unwrap_or_else(|e| panic!("{alg:?}/{pw}: {e:?}"));
|
||||
let page = hay.pages().first().unwrap().page_stream().map(|s| String::from_utf8_lossy(s).trim().to_string());
|
||||
assert_eq!(page.as_deref(), Some(SECRET), "{alg:?}/{pw}");
|
||||
}
|
||||
assert!(hayro_syntax::Pdf::new(bytes.as_ref().clone()).is_err(), "{alg:?}: hayro must not open it without a password");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_user_password_opens_but_reports_restrictions() {
|
||||
let bytes = Arc::new(protect(Algorithm::Aes256, "", "owner", 0b0100)); // print only
|
||||
let doc = Document::open(bytes.clone()).unwrap();
|
||||
let p = doc.permissions().unwrap();
|
||||
assert!(p.print() && !p.modify() && !p.copy() && !p.assemble());
|
||||
let owner = Document::open_with_password(bytes, Some("owner")).unwrap();
|
||||
assert!(owner.permissions().unwrap().modify(), "the owner may do everything");
|
||||
assert!(Document::open(Arc::new(fixture())).unwrap().permissions().is_none(), "unencrypted: no restrictions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_edits_of_encrypted_documents_stay_encrypted() {
|
||||
for alg in ALL {
|
||||
let original = protect(alg, "user", "owner", -1);
|
||||
let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap();
|
||||
let info = doc.trailer().reference(b"Info").unwrap();
|
||||
doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised figures")))).unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert_eq!(&saved[..original.len()], &original[..], "{alg:?}: incremental");
|
||||
assert!(!String::from_utf8_lossy(&saved).contains("Revised"), "{alg:?}: new string encrypted");
|
||||
let again = Document::open_with_password(Arc::new(saved.clone()), Some("user")).unwrap();
|
||||
assert_eq!(title(&again).as_deref(), Some("Revised figures"), "{alg:?}");
|
||||
assert_eq!(content(&again), SECRET);
|
||||
assert!(hayro_syntax::Pdf::new_with_password(saved, "user").is_ok(), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_and_removing_passwords() {
|
||||
let bytes = protect(Algorithm::Rc4_128, "old", "old-owner", -1);
|
||||
// Change: decrypt with the old handler, encrypt with the new one.
|
||||
let mut doc = Document::open_with_password(Arc::new(bytes), Some("old-owner")).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: Algorithm::Aes256,
|
||||
user_password: "new",
|
||||
owner_password: "new-owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let changed = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap()); // falls back to a full save
|
||||
assert!(Document::open_with_password(changed.clone(), Some("old")).is_err());
|
||||
let doc = Document::open_with_password(changed.clone(), Some("new")).unwrap();
|
||||
assert_eq!(content(&doc), SECRET);
|
||||
assert_eq!(doc.security().unwrap().dict().r, 6);
|
||||
// Remove.
|
||||
let mut doc = Document::open_with_password(changed, Some("new-owner")).unwrap();
|
||||
doc.remove_encryption();
|
||||
let plain = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&plain).contains("Secret page"));
|
||||
let doc = Document::open(Arc::new(plain.clone())).unwrap();
|
||||
assert!(doc.security().is_none() && !doc.trailer().contains(b"Encrypt"));
|
||||
assert_eq!(title(&doc).as_deref(), Some("Quarterly numbers"));
|
||||
assert!(hayro_syntax::Pdf::new(plain).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unencrypted_metadata_is_left_readable() {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "o",
|
||||
permissions: -1,
|
||||
encrypt_metadata: false,
|
||||
seed: [5; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("<x:xmpmeta>meta</x:xmpmeta>"), "search engines can read XMP");
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"));
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let meta = doc.get(catalog.reference(b"Metadata").unwrap());
|
||||
let Object::Stream(s) = meta.as_ref() else { panic!() };
|
||||
assert_eq!(s.decoded().unwrap(), b"<x:xmpmeta>meta</x:xmpmeta>");
|
||||
let _ = ObjRef::new(1, 0);
|
||||
}
|
||||
|
||||
/// Second independent oracle when qpdf is installed: it must accept both passwords, identify
|
||||
/// the owner password, and find no structural errors.
|
||||
#[test]
|
||||
fn qpdf_agrees() {
|
||||
if std::process::Command::new("qpdf").arg("--version").output().is_err() {
|
||||
eprintln!("qpdf not installed; skipping");
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("printcraft-enc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for alg in ALL {
|
||||
let path = dir.join(format!("{alg:?}.pdf"));
|
||||
std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap();
|
||||
for (pw, expect) in [("user", "user password"), ("owner", "owner password")] {
|
||||
let out = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}");
|
||||
let check = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap();
|
||||
assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "printcraft-crypt"
|
||||
description = "PDF standard security handler (ISO 32000-2 §7.6): RC4 and AES, revisions 2–6"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
aes = "0.9"
|
||||
md-5 = "0.11"
|
||||
sha2 = "0.11"
|
||||
stringprep = "0.1"
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
//! AES-CBC (128 or 256-bit key, chosen by key length) with optional PKCS#5 padding.
|
||||
//!
|
||||
//! Decryption is tolerant, as viewers are: a trailing partial block is ignored, and invalid
|
||||
//! padding leaves the data unpadded rather than failing.
|
||||
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
use aes::{Aes128, Aes256};
|
||||
|
||||
enum Cipher {
|
||||
A128(Box<Aes128>),
|
||||
A256(Box<Aes256>),
|
||||
}
|
||||
|
||||
impl Cipher {
|
||||
fn new(key: &[u8]) -> Self {
|
||||
if key.len() >= 32 {
|
||||
let k: [u8; 32] = key[..32].try_into().expect("32 bytes");
|
||||
Cipher::A256(Box::new(Aes256::new(&Array::from(k))))
|
||||
} else {
|
||||
let mut k = [0u8; 16];
|
||||
let n = key.len().min(16);
|
||||
k[..n].copy_from_slice(&key[..n]);
|
||||
Cipher::A128(Box::new(Aes128::new(&Array::from(k))))
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt(&self, block: &mut [u8; 16]) {
|
||||
let mut b = Array::from(*block);
|
||||
match self {
|
||||
Cipher::A128(c) => c.encrypt_block(&mut b),
|
||||
Cipher::A256(c) => c.encrypt_block(&mut b),
|
||||
}
|
||||
*block = b.into();
|
||||
}
|
||||
|
||||
fn decrypt(&self, block: &mut [u8; 16]) {
|
||||
let mut b = Array::from(*block);
|
||||
match self {
|
||||
Cipher::A128(c) => c.decrypt_block(&mut b),
|
||||
Cipher::A256(c) => c.decrypt_block(&mut b),
|
||||
}
|
||||
*block = b.into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt; with `pad`, PKCS#5 padding is added (otherwise `data` must be whole blocks, and a
|
||||
/// trailing partial block is zero-padded).
|
||||
pub fn aes_cbc_encrypt(key: &[u8], iv: &[u8; 16], data: &[u8], pad: bool) -> Vec<u8> {
|
||||
let c = Cipher::new(key);
|
||||
let mut buf = data.to_vec();
|
||||
if pad {
|
||||
let n = 16 - buf.len() % 16;
|
||||
buf.extend(std::iter::repeat_n(n as u8, n));
|
||||
} else if !buf.len().is_multiple_of(16) {
|
||||
buf.resize(buf.len().div_ceil(16) * 16, 0);
|
||||
}
|
||||
let mut prev = *iv;
|
||||
for chunk in buf.chunks_mut(16) {
|
||||
let mut b: [u8; 16] = chunk.try_into().expect("16 bytes");
|
||||
for (x, p) in b.iter_mut().zip(prev) {
|
||||
*x ^= p;
|
||||
}
|
||||
c.encrypt(&mut b);
|
||||
chunk.copy_from_slice(&b);
|
||||
prev = b;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Decrypt; with `pad`, PKCS#5 padding is removed when valid.
|
||||
pub fn aes_cbc_decrypt(key: &[u8], iv: &[u8; 16], data: &[u8], pad: bool) -> Vec<u8> {
|
||||
let c = Cipher::new(key);
|
||||
let whole = data.len() / 16 * 16;
|
||||
let mut out = Vec::with_capacity(whole);
|
||||
let mut prev = *iv;
|
||||
for chunk in data[..whole].chunks(16) {
|
||||
let cipher: [u8; 16] = chunk.try_into().expect("16 bytes");
|
||||
let mut b = cipher;
|
||||
c.decrypt(&mut b);
|
||||
for (x, p) in b.iter_mut().zip(prev) {
|
||||
*x ^= p;
|
||||
}
|
||||
out.extend_from_slice(&b);
|
||||
prev = cipher;
|
||||
}
|
||||
if pad && let Some(&n) = out.last() {
|
||||
let n = n as usize;
|
||||
if (1..=16).contains(&n) && n <= out.len() && out[out.len() - n..].iter().all(|b| *b as usize == n) {
|
||||
out.truncate(out.len() - n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -0,0 +1,578 @@
|
|||
//! printcraft-crypt — the PDF standard security handler (ISO 32000-2 §7.6), standalone (L0).
|
||||
//!
|
||||
//! Supports every revision of the standard handler:
|
||||
//! - R2 (40-bit RC4), R3 (RC4 up to 128-bit), R4 (crypt filters: RC4 or AES-128);
|
||||
//! - R5 (the deprecated AES-256 extension) and R6 (AES-256, PDF 2.0).
|
||||
//!
|
||||
//! It authenticates with the user or owner password (owner first, as viewers do) and derives
|
||||
//! per-object keys. It decrypts and encrypts strings and streams, and honours crypt filters,
|
||||
//! including `Identity` and embedded-file-only encryption. It can also *create* encryption
|
||||
//! dictionaries, which security settings (M8) and our own test fixtures use.
|
||||
//!
|
||||
//! The crate knows nothing about the COS object model. Callers pass in the values of the
|
||||
//! `/Encrypt` dictionary and the first element of the trailer `/ID`.
|
||||
|
||||
mod aes_cbc;
|
||||
mod rc4;
|
||||
|
||||
use md5::{Digest as _, Md5};
|
||||
use sha2::{Sha256, Sha384, Sha512};
|
||||
|
||||
pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt};
|
||||
pub use rc4::rc4;
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
|
||||
pub enum CryptError {
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
#[error("unsupported security handler: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("malformed encryption dictionary: {0}")]
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
/// 32-byte padding string used by revisions 2–4 (§7.6.4.3.2, step a).
|
||||
pub const PADDING: [u8; 32] = [
|
||||
0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80,
|
||||
0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A,
|
||||
];
|
||||
|
||||
/// How a crypt filter encrypts (`/CFM`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
/// `None` / the `Identity` filter: data is not encrypted.
|
||||
Identity,
|
||||
/// `V2`: RC4.
|
||||
Rc4,
|
||||
/// `AESV2`: AES-128-CBC.
|
||||
Aes128,
|
||||
/// `AESV3`: AES-256-CBC.
|
||||
Aes256,
|
||||
}
|
||||
|
||||
/// The values of an `/Encrypt` dictionary that the handler needs.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct EncryptDict {
|
||||
/// `/Filter` (must be `Standard`).
|
||||
pub filter: Vec<u8>,
|
||||
pub v: i64,
|
||||
pub r: i64,
|
||||
/// Key length in bits (`/Length`; 40 when absent).
|
||||
pub length_bits: i64,
|
||||
pub o: Vec<u8>,
|
||||
pub u: Vec<u8>,
|
||||
pub oe: Vec<u8>,
|
||||
pub ue: Vec<u8>,
|
||||
pub perms: Vec<u8>,
|
||||
pub p: i32,
|
||||
pub encrypt_metadata: bool,
|
||||
/// Crypt filters (`/CF`): name → (method, key length in bytes if given).
|
||||
pub crypt_filters: Vec<(Vec<u8>, Method)>,
|
||||
pub stm_f: Vec<u8>,
|
||||
pub str_f: Vec<u8>,
|
||||
pub ef_f: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Which password authenticated the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Auth {
|
||||
Owner,
|
||||
User,
|
||||
}
|
||||
|
||||
/// Permission bits from `/P` (§7.6.4.2, Table 22). Owners have every permission.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Permissions {
|
||||
pub bits: i32,
|
||||
pub owner: bool,
|
||||
}
|
||||
|
||||
impl Permissions {
|
||||
fn bit(&self, n: u32) -> bool {
|
||||
self.owner || (self.bits >> (n - 1)) & 1 == 1
|
||||
}
|
||||
pub fn print(&self) -> bool {
|
||||
self.bit(3)
|
||||
}
|
||||
/// Modify contents other than the operations below.
|
||||
pub fn modify(&self) -> bool {
|
||||
self.bit(4)
|
||||
}
|
||||
pub fn copy(&self) -> bool {
|
||||
self.bit(5)
|
||||
}
|
||||
pub fn annotate(&self) -> bool {
|
||||
self.bit(6)
|
||||
}
|
||||
pub fn fill_forms(&self) -> bool {
|
||||
self.bit(9) || self.bit(6)
|
||||
}
|
||||
pub fn extract_for_accessibility(&self) -> bool {
|
||||
self.bit(10)
|
||||
}
|
||||
/// Insert, rotate or delete pages; create bookmarks and thumbnails.
|
||||
pub fn assemble(&self) -> bool {
|
||||
self.bit(11) || self.bit(4)
|
||||
}
|
||||
pub fn print_high_quality(&self) -> bool {
|
||||
self.bit(12)
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of stream is being decrypted (selects the crypt filter).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StreamKind<'a> {
|
||||
Normal,
|
||||
/// An embedded file stream (uses `/EFF`).
|
||||
EmbeddedFile,
|
||||
/// The stream names its own crypt filter (`/Filter [/Crypt …] /DecodeParms << /Name … >>`).
|
||||
Named(&'a [u8]),
|
||||
}
|
||||
|
||||
/// An authenticated security handler for one document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SecurityHandler {
|
||||
dict: EncryptDict,
|
||||
key: Vec<u8>,
|
||||
auth: Auth,
|
||||
recovered_user: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SecurityHandler {
|
||||
/// Authenticate with `password` (tried as owner password, then user password). `None`
|
||||
/// tries the empty password, which opens documents that only have an owner password.
|
||||
pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result<Self, CryptError> {
|
||||
if !dict.filter.is_empty() && dict.filter != b"Standard" {
|
||||
return Err(CryptError::Unsupported(format!("/Filter /{}", String::from_utf8_lossy(&dict.filter))));
|
||||
}
|
||||
let pw = password.unwrap_or("");
|
||||
let mut recovered_user = None;
|
||||
let (key, auth) = match dict.r {
|
||||
2..=4 => {
|
||||
let legacy = legacy_password(pw);
|
||||
if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) {
|
||||
recovered_user = Some(user);
|
||||
(k, Auth::Owner)
|
||||
} else if let Some(k) = user_key_r4(&dict, id0, &legacy) {
|
||||
(k, Auth::User)
|
||||
} else {
|
||||
return Err(CryptError::WrongPassword);
|
||||
}
|
||||
}
|
||||
5 | 6 => {
|
||||
let pw = sasl_password(pw);
|
||||
if let Some(k) = key_r6(&dict, &pw, true)? {
|
||||
(k, Auth::Owner)
|
||||
} else if let Some(k) = key_r6(&dict, &pw, false)? {
|
||||
(k, Auth::User)
|
||||
} else {
|
||||
return Err(CryptError::WrongPassword);
|
||||
}
|
||||
}
|
||||
r => return Err(CryptError::Unsupported(format!("revision {r}"))),
|
||||
};
|
||||
Ok(Self { dict, key, auth, recovered_user })
|
||||
}
|
||||
|
||||
pub fn auth(&self) -> Auth {
|
||||
self.auth
|
||||
}
|
||||
|
||||
/// For R2–R4 documents opened with the owner password: the user password, which owner
|
||||
/// authentication recovers (Algorithm 7). Lets components that only accept user passwords
|
||||
/// open the document too.
|
||||
pub fn recovered_user_password(&self) -> Option<Vec<u8>> {
|
||||
self.recovered_user.clone()
|
||||
}
|
||||
|
||||
pub fn permissions(&self) -> Permissions {
|
||||
Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner }
|
||||
}
|
||||
|
||||
pub fn dict(&self) -> &EncryptDict {
|
||||
&self.dict
|
||||
}
|
||||
|
||||
/// The file encryption key (for tests and diagnostics).
|
||||
pub fn file_key(&self) -> &[u8] {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// Whether `/Metadata` streams are encrypted.
|
||||
pub fn encrypts_metadata(&self) -> bool {
|
||||
self.dict.encrypt_metadata
|
||||
}
|
||||
|
||||
fn method_named(&self, name: &[u8]) -> Method {
|
||||
if name == b"Identity" || name.is_empty() {
|
||||
return Method::Identity;
|
||||
}
|
||||
self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity)
|
||||
}
|
||||
|
||||
/// The method for strings or a kind of stream.
|
||||
fn method(&self, stream: Option<StreamKind>) -> Method {
|
||||
if self.dict.v < 4 {
|
||||
return Method::Rc4;
|
||||
}
|
||||
match stream {
|
||||
None => self.method_named(&self.dict.str_f),
|
||||
Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f),
|
||||
Some(StreamKind::EmbeddedFile) => {
|
||||
let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f };
|
||||
self.method_named(eff)
|
||||
}
|
||||
Some(StreamKind::Named(n)) => self.method_named(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// Algorithm 1 (§7.6.3.3): the key for one object. AES-256 uses the file key directly.
|
||||
fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec<u8> {
|
||||
if method == Method::Aes256 {
|
||||
return self.key.clone();
|
||||
}
|
||||
let mut h = Md5::new();
|
||||
h.update(&self.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()
|
||||
}
|
||||
|
||||
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
|
||||
match method {
|
||||
Method::Identity => data.to_vec(),
|
||||
Method::Rc4 => rc4(&self.object_key(method, num, generation), data),
|
||||
Method::Aes128 | Method::Aes256 => {
|
||||
let key = self.object_key(method, num, generation);
|
||||
if encrypt {
|
||||
// Deterministic IV derived from the key, object and content: saves are
|
||||
// reproducible and no IV repeats for different plaintexts.
|
||||
let mut h = Sha256::new();
|
||||
h.update(&key);
|
||||
h.update(num.to_le_bytes());
|
||||
h.update(generation.to_le_bytes());
|
||||
h.update(data);
|
||||
let iv: [u8; 16] = h.finalize()[..16].try_into().expect("16 bytes");
|
||||
let mut out = iv.to_vec();
|
||||
out.extend(aes_cbc_encrypt(&key, &iv, data, true));
|
||||
out
|
||||
} else {
|
||||
if data.len() < 16 {
|
||||
return Vec::new(); // only (part of) an IV: empty plaintext
|
||||
}
|
||||
let (iv, body) = data.split_at(16);
|
||||
aes_cbc_decrypt(&key, iv.try_into().expect("16 bytes"), body, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrypt_string(&self, num: u32, generation: u16, data: &[u8]) -> Vec<u8> {
|
||||
self.apply(self.method(None), num, generation, data, false)
|
||||
}
|
||||
|
||||
pub fn encrypt_string(&self, num: u32, generation: u16, data: &[u8]) -> Vec<u8> {
|
||||
self.apply(self.method(None), num, generation, data, true)
|
||||
}
|
||||
|
||||
pub fn decrypt_stream(&self, num: u32, generation: u16, data: &[u8], kind: StreamKind) -> Vec<u8> {
|
||||
self.apply(self.method(Some(kind)), num, generation, data, false)
|
||||
}
|
||||
|
||||
pub fn encrypt_stream(&self, num: u32, generation: u16, data: &[u8], kind: StreamKind) -> Vec<u8> {
|
||||
self.apply(self.method(Some(kind)), num, generation, data, true)
|
||||
}
|
||||
|
||||
/// Whether strings are encrypted at all (false with `/StrF /Identity`).
|
||||
pub fn encrypts_strings(&self) -> bool {
|
||||
self.method(None) != Method::Identity
|
||||
}
|
||||
}
|
||||
|
||||
// ── Passwords ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Revisions 2–4 use PDFDocEncoding passwords; characters outside Latin-1 are dropped.
|
||||
fn legacy_password(pw: &str) -> Vec<u8> {
|
||||
pw.chars().filter_map(|c| u8::try_from(u32::from(c)).ok()).take(32).collect()
|
||||
}
|
||||
|
||||
/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes.
|
||||
fn sasl_password(pw: &str) -> Vec<u8> {
|
||||
let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string());
|
||||
let mut bytes = prepped.into_bytes();
|
||||
bytes.truncate(127);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn pad(pw: &[u8]) -> [u8; 32] {
|
||||
let mut out = PADDING;
|
||||
let n = pw.len().min(32);
|
||||
out[..n].copy_from_slice(&pw[..n]);
|
||||
out[n..].copy_from_slice(&PADDING[..32 - n]);
|
||||
out
|
||||
}
|
||||
|
||||
fn key_len(d: &EncryptDict) -> usize {
|
||||
if d.r == 2 { 5 } else { (d.length_bits.clamp(40, 128) / 8) as usize }
|
||||
}
|
||||
|
||||
// ── Revisions 2–4 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Algorithm 2: file key from a (user) password.
|
||||
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec<u8> {
|
||||
let n = key_len(d);
|
||||
let mut h = Md5::new();
|
||||
h.update(pad(pw));
|
||||
h.update(&d.o[..d.o.len().min(32)]);
|
||||
h.update(d.p.to_le_bytes());
|
||||
h.update(id0);
|
||||
if d.r >= 4 && !d.encrypt_metadata {
|
||||
h.update([0xFF; 4]);
|
||||
}
|
||||
let mut digest = h.finalize().to_vec();
|
||||
if d.r >= 3 {
|
||||
for _ in 0..50 {
|
||||
digest = Md5::digest(&digest[..n]).to_vec();
|
||||
}
|
||||
}
|
||||
digest.truncate(n);
|
||||
digest
|
||||
}
|
||||
|
||||
/// Algorithms 4/5: the `/U` value a key produces.
|
||||
fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
if d.r == 2 {
|
||||
return rc4(key, &PADDING);
|
||||
}
|
||||
let mut h = Md5::new();
|
||||
h.update(PADDING);
|
||||
h.update(id0);
|
||||
let mut x = rc4(key, &h.finalize());
|
||||
for i in 1..=19u8 {
|
||||
let k: Vec<u8> = key.iter().map(|b| b ^ i).collect();
|
||||
x = rc4(&k, &x);
|
||||
}
|
||||
x.extend_from_slice(&[0u8; 16]);
|
||||
x
|
||||
}
|
||||
|
||||
/// Algorithm 6: authenticate a user password; returns the file key.
|
||||
///
|
||||
/// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the
|
||||
/// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation
|
||||
/// fails we retry the other way.
|
||||
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
|
||||
let check = |d: &EncryptDict| {
|
||||
let key = file_key_r4(d, id0, pw);
|
||||
let u = u_value_r4(d, id0, &key);
|
||||
let n = if d.r == 2 { 32 } else { 16 };
|
||||
(d.u.len() >= n && u[..n] == d.u[..n]).then_some(key)
|
||||
};
|
||||
check(d).or_else(|| (d.r >= 4 && !d.encrypt_metadata).then(|| check(&EncryptDict { encrypt_metadata: true, ..d.clone() })).flatten())
|
||||
}
|
||||
|
||||
/// The RC4 key derived from an owner password (Algorithm 3, steps a–d).
|
||||
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec<u8> {
|
||||
let n = key_len(d);
|
||||
let mut digest = Md5::digest(pad(owner_pw)).to_vec();
|
||||
if d.r >= 3 {
|
||||
for _ in 0..50 {
|
||||
digest = Md5::digest(&digest).to_vec();
|
||||
}
|
||||
}
|
||||
digest.truncate(n);
|
||||
digest
|
||||
}
|
||||
|
||||
/// Algorithm 7: authenticate an owner password (recover the user password from `/O`).
|
||||
/// Returns the file key and the recovered user password (padding removed).
|
||||
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let rk = owner_rc4_key(d, pw);
|
||||
let mut user = d.o[..d.o.len().min(32)].to_vec();
|
||||
if d.r == 2 {
|
||||
user = rc4(&rk, &user);
|
||||
} else {
|
||||
for i in (0..=19u8).rev() {
|
||||
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
|
||||
user = rc4(&k, &user);
|
||||
}
|
||||
}
|
||||
let key = user_key_r4(d, id0, &user)?;
|
||||
// The recovered value is the padded password: strip the padding suffix.
|
||||
let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32));
|
||||
Some((key, user[..len].to_vec()))
|
||||
}
|
||||
|
||||
// ── Revisions 5–6 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B).
|
||||
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
let mut k = {
|
||||
let mut h = Sha256::new();
|
||||
h.update(pw);
|
||||
h.update(salt);
|
||||
h.update(udata);
|
||||
h.finalize().to_vec()
|
||||
};
|
||||
if r == 5 {
|
||||
return k;
|
||||
}
|
||||
let mut round = 0u32;
|
||||
loop {
|
||||
let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()));
|
||||
for _ in 0..64 {
|
||||
k1.extend_from_slice(pw);
|
||||
k1.extend_from_slice(&k);
|
||||
k1.extend_from_slice(udata);
|
||||
}
|
||||
let e = aes_cbc_encrypt(&k[..16], k[16..32].try_into().expect("16 bytes"), &k1, false);
|
||||
let m = e[..16].iter().map(|b| u32::from(*b)).sum::<u32>() % 3;
|
||||
k = match m {
|
||||
0 => Sha256::digest(&e).to_vec(),
|
||||
1 => Sha384::digest(&e).to_vec(),
|
||||
_ => Sha512::digest(&e).to_vec(),
|
||||
};
|
||||
round += 1;
|
||||
if round >= 64 && u32::from(*e.last().expect("non-empty")) <= round - 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
k.truncate(32);
|
||||
k
|
||||
}
|
||||
|
||||
/// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key.
|
||||
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Vec<u8>>, CryptError> {
|
||||
if d.u.len() < 48 || d.o.len() < 48 {
|
||||
return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into()));
|
||||
}
|
||||
let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) };
|
||||
if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
|
||||
return Ok(None);
|
||||
}
|
||||
if encrypted.len() < 32 {
|
||||
return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into()));
|
||||
}
|
||||
let k = hash_r6(d.r, pw, &hashed[40..48], udata);
|
||||
Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))
|
||||
}
|
||||
|
||||
// ── Creating encryption ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which encryption to create.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Algorithm {
|
||||
/// R2, 40-bit RC4 (legacy; for tests and compatibility only).
|
||||
Rc4_40,
|
||||
/// R3, 128-bit RC4.
|
||||
Rc4_128,
|
||||
/// R4, AES-128 crypt filters.
|
||||
Aes128,
|
||||
/// R6, AES-256 (PDF 2.0; the default for new documents).
|
||||
Aes256,
|
||||
}
|
||||
|
||||
/// Parameters for new encryption.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewEncryption<'a> {
|
||||
pub algorithm: Algorithm,
|
||||
pub user_password: &'a str,
|
||||
pub owner_password: &'a str,
|
||||
pub permissions: i32,
|
||||
pub encrypt_metadata: bool,
|
||||
/// 32 bytes of entropy for keys and salts (the caller supplies randomness, so this crate
|
||||
/// needs no OS access and tests are deterministic).
|
||||
pub seed: [u8; 32],
|
||||
}
|
||||
|
||||
/// Expand `seed` into `n` bytes (SHA-256 in counter mode).
|
||||
fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut counter = 0u32;
|
||||
while out.len() < n {
|
||||
let mut h = Sha256::new();
|
||||
h.update(seed);
|
||||
h.update(label);
|
||||
h.update(counter.to_le_bytes());
|
||||
out.extend_from_slice(&h.finalize());
|
||||
counter += 1;
|
||||
}
|
||||
out.truncate(n);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption.
|
||||
pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, CryptError> {
|
||||
// Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2).
|
||||
let p = (params.permissions | !0xFFF | 0xC0) & !0x3;
|
||||
let mut d = EncryptDict { filter: b"Standard".to_vec(), p, encrypt_metadata: params.encrypt_metadata, ..Default::default() };
|
||||
match params.algorithm {
|
||||
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => {
|
||||
(d.v, d.r, d.length_bits) = match params.algorithm {
|
||||
Algorithm::Rc4_40 => (1, 2, 40),
|
||||
Algorithm::Rc4_128 => (2, 3, 128),
|
||||
_ => (4, 4, 128),
|
||||
};
|
||||
if params.algorithm == Algorithm::Aes128 {
|
||||
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)];
|
||||
d.stm_f = b"StdCF".to_vec();
|
||||
d.str_f = b"StdCF".to_vec();
|
||||
} else {
|
||||
d.encrypt_metadata = true; // V < 4 always encrypts metadata
|
||||
}
|
||||
let user = legacy_password(params.user_password);
|
||||
let owner = if params.owner_password.is_empty() { user.clone() } else { legacy_password(params.owner_password) };
|
||||
// Algorithm 3: /O
|
||||
let rk = owner_rc4_key(&d, &owner);
|
||||
let mut o = rc4(&rk, &pad(&user));
|
||||
if d.r >= 3 {
|
||||
for i in 1..=19u8 {
|
||||
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
|
||||
o = rc4(&k, &o);
|
||||
}
|
||||
}
|
||||
d.o = o;
|
||||
let key = file_key_r4(&d, id0, &user);
|
||||
d.u = u_value_r4(&d, id0, &key);
|
||||
Ok(SecurityHandler { dict: d, key, auth: Auth::Owner, recovered_user: Some(user) })
|
||||
}
|
||||
Algorithm::Aes256 => {
|
||||
(d.v, d.r, d.length_bits) = (5, 6, 256);
|
||||
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes256)];
|
||||
d.stm_f = b"StdCF".to_vec();
|
||||
d.str_f = b"StdCF".to_vec();
|
||||
let key = expand(¶ms.seed, b"file key", 32);
|
||||
let salts = expand(¶ms.seed, b"salts", 32);
|
||||
let user = sasl_password(params.user_password);
|
||||
let owner = sasl_password(params.owner_password);
|
||||
// Algorithm 8: /U, /UE
|
||||
let mut u = hash_r6(6, &user, &salts[0..8], &[]);
|
||||
u.extend_from_slice(&salts[0..16]);
|
||||
let uk = hash_r6(6, &user, &salts[8..16], &[]);
|
||||
d.ue = aes_cbc_encrypt(&uk, &[0; 16], &key, false);
|
||||
// Algorithm 9: /O, /OE
|
||||
let mut o = hash_r6(6, &owner, &salts[16..24], &u);
|
||||
o.extend_from_slice(&salts[16..32]);
|
||||
let ok = hash_r6(6, &owner, &salts[24..32], &u);
|
||||
d.oe = aes_cbc_encrypt(&ok, &[0; 16], &key, false);
|
||||
d.u = u;
|
||||
d.o = o;
|
||||
// Algorithm 10: /Perms
|
||||
let mut perms = [0u8; 16];
|
||||
perms[..4].copy_from_slice(&p.to_le_bytes());
|
||||
perms[4..8].copy_from_slice(&[0xFF; 4]);
|
||||
perms[8] = if d.encrypt_metadata { b'T' } else { b'F' };
|
||||
perms[9..12].copy_from_slice(b"adb");
|
||||
perms[12..16].copy_from_slice(&expand(¶ms.seed, b"perms", 4));
|
||||
d.perms = aes_cbc_encrypt(&key, &[0; 16], &perms, false);
|
||||
Ok(SecurityHandler { dict: d, key, auth: Auth::Owner, recovered_user: None })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//! RC4 (used by PDF security handler revisions 2–4). Encryption and decryption are the same.
|
||||
|
||||
pub fn rc4(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
if key.is_empty() {
|
||||
return data.to_vec();
|
||||
}
|
||||
let mut s: [u8; 256] = std::array::from_fn(|i| i as u8);
|
||||
let mut j: u8 = 0;
|
||||
for i in 0..256 {
|
||||
j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]);
|
||||
s.swap(i, j as usize);
|
||||
}
|
||||
let (mut i, mut j) = (0u8, 0u8);
|
||||
data.iter()
|
||||
.map(|b| {
|
||||
i = i.wrapping_add(1);
|
||||
j = j.wrapping_add(s[i as usize]);
|
||||
s.swap(i as usize, j as usize);
|
||||
b ^ s[s[i as usize].wrapping_add(s[j as usize]) as usize]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
use super::*;
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc4_reference_vectors() {
|
||||
assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3"));
|
||||
assert_eq!(rc4(b"Wiki", b"pedia"), hex("1021bf0420"));
|
||||
assert_eq!(rc4(b"Secret", b"Attack at dawn"), hex("45a01f645fc35b383552544b9bf5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_cbc_nist_sp800_38a_vectors() {
|
||||
let iv: [u8; 16] = hex("000102030405060708090a0b0c0d0e0f").try_into().unwrap();
|
||||
let plain = hex("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51");
|
||||
let k128 = hex("2b7e151628aed2a6abf7158809cf4f3c");
|
||||
let c128 = aes_cbc_encrypt(&k128, &iv, &plain, false);
|
||||
assert_eq!(c128, hex("7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2"));
|
||||
assert_eq!(aes_cbc_decrypt(&k128, &iv, &c128, false), plain);
|
||||
let k256 = hex("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4");
|
||||
let c256 = aes_cbc_encrypt(&k256, &iv, &plain, false);
|
||||
assert_eq!(c256, hex("f58c4c04d6e5f1ba779eabfb5f7bfbd69cfc4e967edb808d679f777bc6702c7d"));
|
||||
assert_eq!(aes_cbc_decrypt(&k256, &iv, &c256, false), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_padding_is_added_and_tolerantly_removed() {
|
||||
let key = [7u8; 16];
|
||||
let iv = [1u8; 16];
|
||||
for n in [0, 1, 15, 16, 17, 100] {
|
||||
let data: Vec<u8> = (0..n as u8).collect();
|
||||
let c = aes_cbc_encrypt(&key, &iv, &data, true);
|
||||
assert_eq!(c.len() % 16, 0);
|
||||
assert_eq!(aes_cbc_decrypt(&key, &iv, &c, true), data);
|
||||
}
|
||||
// Garbage (bad padding, partial block) decrypts without panicking.
|
||||
let _ = aes_cbc_decrypt(&key, &iv, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], true);
|
||||
}
|
||||
|
||||
const ID: &[u8] = b"0123456789abcdef";
|
||||
|
||||
fn new(alg: Algorithm, user: &str, owner: &str, perms: i32) -> SecurityHandler {
|
||||
create(
|
||||
&NewEncryption { algorithm: alg, user_password: user, owner_password: owner, permissions: perms, encrypt_metadata: true, seed: [42; 32] },
|
||||
ID,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const ALL: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
|
||||
|
||||
#[test]
|
||||
fn created_encryption_authenticates_owner_and_user() {
|
||||
for alg in ALL {
|
||||
let made = new(alg, "user pw", "owner pw", 0b0100); // print only
|
||||
let d = made.dict().clone();
|
||||
let owner = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
|
||||
assert_eq!(owner.auth(), Auth::Owner, "{alg:?}");
|
||||
assert_eq!(owner.file_key(), made.file_key(), "{alg:?}");
|
||||
assert!(owner.permissions().modify(), "owners can do everything");
|
||||
let user = SecurityHandler::open(d.clone(), ID, Some("user pw")).unwrap();
|
||||
assert_eq!(user.auth(), Auth::User, "{alg:?}");
|
||||
assert_eq!(user.file_key(), made.file_key(), "{alg:?}");
|
||||
assert!(user.permissions().print() && !user.permissions().modify() && !user.permissions().copy(), "{alg:?}");
|
||||
assert_eq!(SecurityHandler::open(d.clone(), ID, Some("nope")).unwrap_err(), CryptError::WrongPassword, "{alg:?}");
|
||||
assert_eq!(SecurityHandler::open(d, ID, None).unwrap_err(), CryptError::WrongPassword, "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_user_password_opens_without_asking() {
|
||||
for alg in ALL {
|
||||
let d = new(alg, "", "secret", -1).dict().clone();
|
||||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||||
assert_eq!(h.auth(), Auth::User, "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_streams_round_trip_and_are_scrambled() {
|
||||
for alg in ALL {
|
||||
let h = new(alg, "", "o", -1);
|
||||
let text = b"Confidential quarterly numbers".to_vec();
|
||||
let enc = h.encrypt_string(12, 0, &text);
|
||||
assert_ne!(enc, text, "{alg:?}");
|
||||
assert_eq!(h.decrypt_string(12, 0, &enc), text, "{alg:?}");
|
||||
if alg != Algorithm::Aes256 {
|
||||
// R2–R4 derive a key per object; R6 uses the file key everywhere (§7.6.3.3).
|
||||
assert_ne!(h.decrypt_string(13, 0, &enc), text, "object keys differ per object ({alg:?})");
|
||||
}
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i * 7) as u8).collect();
|
||||
let enc = h.encrypt_stream(5, 0, &data, StreamKind::Normal);
|
||||
assert_eq!(h.decrypt_stream(5, 0, &enc, StreamKind::Normal), data, "{alg:?}");
|
||||
// Deterministic: the same object encrypts to the same bytes (reproducible saves).
|
||||
assert_eq!(h.encrypt_stream(5, 0, &data, StreamKind::Normal), enc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_crypt_filters_leave_data_alone() {
|
||||
let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone();
|
||||
// Embedded-file-only encryption: strings and normal streams are not encrypted.
|
||||
d.str_f = b"Identity".to_vec();
|
||||
d.stm_f = b"Identity".to_vec();
|
||||
d.ef_f = b"StdCF".to_vec();
|
||||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||||
assert!(!h.encrypts_strings());
|
||||
assert_eq!(h.decrypt_stream(3, 0, b"plain", StreamKind::Normal), b"plain");
|
||||
let enc = h.encrypt_stream(3, 0, b"attachment", StreamKind::EmbeddedFile);
|
||||
assert_ne!(enc, b"attachment");
|
||||
assert_eq!(h.decrypt_stream(3, 0, &enc, StreamKind::EmbeddedFile), b"attachment");
|
||||
assert_eq!(h.decrypt_stream(3, 0, b"x", StreamKind::Named(b"Identity")), b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_passwords_are_saslprepped() {
|
||||
// U+00AD (soft hyphen) is mapped to nothing by SASLprep; "ª" normalises to "a" (NFKC).
|
||||
let d = new(Algorithm::Aes256, "SªSL\u{AD}prep", "o", -1).dict().clone();
|
||||
assert!(SecurityHandler::open(d.clone(), ID, Some("SaSLprep")).is_ok());
|
||||
assert!(SecurityHandler::open(d, ID, Some("SaSL-prep")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_handlers_are_reported() {
|
||||
let mut d = new(Algorithm::Aes128, "", "o", -1).dict().clone();
|
||||
d.filter = b"Adobe.PubSec".to_vec();
|
||||
assert!(matches!(SecurityHandler::open(d.clone(), ID, None), Err(CryptError::Unsupported(_))));
|
||||
d.filter = b"Standard".to_vec();
|
||||
d.r = 7;
|
||||
assert!(matches!(SecurityHandler::open(d, ID, None), Err(CryptError::Unsupported(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_bits_follow_table_22() {
|
||||
let p = Permissions { bits: -3904, owner: false }; // 0xFFFFF0C0: only reserved bits set
|
||||
assert!(!p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.assemble());
|
||||
let p = Permissions { bits: -1, owner: false };
|
||||
assert!(p.print() && p.modify() && p.copy() && p.annotate() && p.assemble() && p.fill_forms());
|
||||
let p = Permissions { bits: 0, owner: true };
|
||||
assert!(p.modify(), "owners ignore /P");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
|
||||
u in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
|
||||
r in 0i64..9, len in -10i64..300) {
|
||||
let d = EncryptDict { filter: b"Standard".to_vec(), v: 4, r, length_bits: len, o, u, p: -1, encrypt_metadata: true, ..Default::default() };
|
||||
let _ = SecurityHandler::open(d, ID, Some("x"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_authentication_recovers_the_user_password() {
|
||||
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||||
let d = new(alg, "user pw", "owner pw", -1).dict().clone();
|
||||
let h = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
|
||||
assert_eq!(h.recovered_user_password().as_deref(), Some(&b"user pw"[..]), "{alg:?}");
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None);
|
||||
}
|
||||
let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone();
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..]));
|
||||
}
|
||||
|
|
@ -108,10 +108,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
|||
ToolSection {
|
||||
title: "Modify page",
|
||||
items: &[
|
||||
item("Rotate pages", "rotate-cw", "page.rotate", Planned("M4")),
|
||||
item("Insert pages", "file-plus-2", "page.insert", Planned("M4")),
|
||||
item("Delete pages", "trash-2", "page.delete", Planned("M4")),
|
||||
item("Extract pages", "file-output", "page.extract", Planned("M4")),
|
||||
item("Rotate pages", "rotate-cw", "page.rotate", Ready),
|
||||
item("Insert pages", "file-plus-2", "page.insert", Ready),
|
||||
item("Delete pages", "trash-2", "page.delete", Ready),
|
||||
item("Extract pages", "file-output", "page.extract", Ready),
|
||||
item("Organize pages", "layout-grid", "page.organize", Ready),
|
||||
],
|
||||
},
|
||||
|
|
@ -154,8 +154,8 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
|||
icon: "files",
|
||||
hue: BLUE,
|
||||
badge: None,
|
||||
availability: Planned("M4"),
|
||||
sections: &[ToolSection { title: "Combine", items: &[item("Add files to combine", "files", "page.combine", Planned("M4"))] }],
|
||||
availability: Ready,
|
||||
sections: &[ToolSection { title: "Combine", items: &[item("Add files to combine", "files", "page.combine", Ready)] }],
|
||||
},
|
||||
ToolGroup {
|
||||
id: "organize",
|
||||
|
|
@ -169,12 +169,12 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
|||
title: "Page options",
|
||||
items: &[
|
||||
item("Page grid", "layout-grid", "page.organize", Ready),
|
||||
item("Rotate", "rotate-cw", "page.rotate", Planned("M4")),
|
||||
item("Delete", "trash-2", "page.delete", Planned("M4")),
|
||||
item("Extract", "file-output", "page.extract", Planned("M4")),
|
||||
item("Insert", "file-plus-2", "page.insert", Planned("M4")),
|
||||
item("Rotate", "rotate-cw", "page.rotate", Ready),
|
||||
item("Delete", "trash-2", "page.delete", Ready),
|
||||
item("Extract", "file-output", "page.extract", Ready),
|
||||
item("Insert", "file-plus-2", "page.insert", Ready),
|
||||
item("Replace", "replace", "page.replace", Planned("M4")),
|
||||
item("Split", "scissors", "page.split", Planned("M4")),
|
||||
item("Split", "scissors", "page.split", Ready),
|
||||
],
|
||||
},
|
||||
ToolSection {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
//! The command registry: every user-facing action, by id (architecture §4, "everything is a
|
||||
//! command").
|
||||
//!
|
||||
//! Menus, keyboard shortcuts, the ⌘K palette and automation (CLI `run`, the control channel and
|
||||
//! MCP later) all go through this table. A frontend implements *what* each id does. The registry
|
||||
//! says what it is called, where it appears, which key runs it, and when it is enabled, so that
|
||||
//! every surface agrees.
|
||||
//!
|
||||
//! View-local keys (zoom, page navigation, find-next) stay with the document view. They act on
|
||||
//! view state, not on the document.
|
||||
|
||||
use crate::{DocId, Session};
|
||||
|
||||
/// When a command can run.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Needs {
|
||||
/// Always.
|
||||
Nothing,
|
||||
/// A document is open.
|
||||
Document,
|
||||
/// The open document's security allows page changes (insert, delete, rotate, extract…).
|
||||
Assembly,
|
||||
/// The open document's security allows content and metadata changes.
|
||||
Modification,
|
||||
/// There is something to undo.
|
||||
Undo,
|
||||
/// There is something to redo.
|
||||
Redo,
|
||||
}
|
||||
|
||||
/// A keyboard shortcut. `command` is ⌘ on macOS and Ctrl elsewhere.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Shortcut {
|
||||
pub command: bool,
|
||||
pub shift: bool,
|
||||
/// ⌃ on macOS (in addition to ⌘); unused elsewhere.
|
||||
pub mac_ctrl: bool,
|
||||
/// Key name: a letter, a digit, or `Delete`.
|
||||
pub key: &'static str,
|
||||
}
|
||||
|
||||
impl Shortcut {
|
||||
const fn cmd(key: &'static str) -> Self {
|
||||
Self { command: true, shift: false, mac_ctrl: false, key }
|
||||
}
|
||||
|
||||
const fn cmd_shift(key: &'static str) -> Self {
|
||||
Self { command: true, shift: true, mac_ctrl: false, key }
|
||||
}
|
||||
|
||||
/// How the shortcut is written in menus: `⇧⌘S` on macOS, `Ctrl+Shift+S` elsewhere.
|
||||
pub fn label(&self, mac: bool) -> String {
|
||||
if mac {
|
||||
let mut s = String::new();
|
||||
if self.mac_ctrl {
|
||||
s.push('⌃');
|
||||
}
|
||||
if self.shift {
|
||||
s.push('⇧');
|
||||
}
|
||||
if self.command {
|
||||
s.push('⌘');
|
||||
}
|
||||
s.push_str(self.key);
|
||||
s
|
||||
} else {
|
||||
let mut parts = Vec::new();
|
||||
if self.command || self.mac_ctrl {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if self.shift {
|
||||
parts.push("Shift");
|
||||
}
|
||||
parts.push(self.key);
|
||||
parts.join("+")
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of modifiers (more specific shortcuts are matched first).
|
||||
pub fn modifier_count(&self) -> usize {
|
||||
usize::from(self.command) + usize::from(self.shift) + usize::from(self.mac_ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CommandSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Top-level menu it appears in (`File`, `Edit`, `View`, `Pages`, `Help`), if any.
|
||||
pub menu: Option<&'static str>,
|
||||
pub shortcut: Option<Shortcut>,
|
||||
pub needs: Needs,
|
||||
/// The shortcut also works while a text field has focus.
|
||||
pub in_text: bool,
|
||||
/// Lucide icon name for palettes and toolbars.
|
||||
pub icon: &'static str,
|
||||
}
|
||||
|
||||
const fn c(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
menu: Option<&'static str>,
|
||||
shortcut: Option<Shortcut>,
|
||||
needs: Needs,
|
||||
icon: &'static str,
|
||||
) -> CommandSpec {
|
||||
CommandSpec { id, label, menu, shortcut, needs, in_text: true, icon }
|
||||
}
|
||||
|
||||
/// Like `c`, but the shortcut is left to text fields while one has focus (⌘Z, ⌘A…).
|
||||
const fn ct(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
menu: Option<&'static str>,
|
||||
shortcut: Option<Shortcut>,
|
||||
needs: Needs,
|
||||
icon: &'static str,
|
||||
) -> CommandSpec {
|
||||
CommandSpec { id, label, menu, shortcut, needs, in_text: false, icon }
|
||||
}
|
||||
|
||||
use Needs::*;
|
||||
|
||||
const FILE: Option<&str> = Some("File");
|
||||
const EDIT: Option<&str> = Some("Edit");
|
||||
const VIEW: Option<&str> = Some("View");
|
||||
const PAGES: Option<&str> = Some("Pages");
|
||||
const HELP: Option<&str> = Some("Help");
|
||||
|
||||
/// Every command, in menu order.
|
||||
pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
|
||||
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
|
||||
c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"),
|
||||
c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"),
|
||||
c("file.close", "Close file", FILE, Some(Shortcut::cmd("W")), Document, "x"),
|
||||
c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"),
|
||||
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
|
||||
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
|
||||
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
|
||||
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
|
||||
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
|
||||
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
|
||||
c("view.theme", "Switch light / dark theme", VIEW, None, Nothing, "moon"),
|
||||
c("comment.list", "Comments panel", VIEW, None, Document, "message-square-text"),
|
||||
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
|
||||
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
|
||||
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
|
||||
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
|
||||
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
|
||||
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
|
||||
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
|
||||
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
|
||||
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
|
||||
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
|
||||
c("help.about", "About PrintCraft", HELP, None, Nothing, "info"),
|
||||
];
|
||||
|
||||
pub fn command(id: &str) -> Option<&'static CommandSpec> {
|
||||
COMMANDS.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Commands that appear in `menu`, in order.
|
||||
pub fn menu(menu: &str) -> impl Iterator<Item = &'static CommandSpec> + '_ {
|
||||
COMMANDS.iter().filter(move |c| c.menu == Some(menu))
|
||||
}
|
||||
|
||||
/// Whether `spec` can run now, for the document `active` (the focused tab).
|
||||
pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>) -> bool {
|
||||
let doc = active.and_then(|id| session.get(id));
|
||||
match spec.needs {
|
||||
Nothing => true,
|
||||
Document => doc.is_some(),
|
||||
Assembly => doc.is_some_and(|d| d.allows_assembly()),
|
||||
Modification => doc.is_some_and(|d| d.allows_modification()),
|
||||
Undo => doc.is_some_and(|d| d.can_undo().is_some()),
|
||||
Redo => doc.is_some_and(|d| d.can_redo().is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The label to show for `spec` now ("Undo Rotate page" rather than "Undo").
|
||||
pub fn current_label(spec: &CommandSpec, session: &Session, active: Option<DocId>) -> String {
|
||||
let doc = active.and_then(|id| session.get(id));
|
||||
match (spec.id, doc) {
|
||||
("edit.undo", Some(d)) => d.can_undo().map(|l| format!("Undo {l}")).unwrap_or_else(|| spec.label.into()),
|
||||
("edit.redo", Some(d)) => d.can_redo().map(|l| format!("Redo {l}")).unwrap_or_else(|| spec.label.into()),
|
||||
_ => spec.label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_and_shortcuts_are_unique() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
for c in COMMANDS {
|
||||
assert!(ids.insert(c.id), "duplicate id {}", c.id);
|
||||
if let Some(s) = c.shortcut {
|
||||
assert!(keys.insert(s), "duplicate shortcut {} ({})", s.label(true), c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_ready_catalogue_item_is_a_registered_command() {
|
||||
for g in crate::catalog::TOOL_GROUPS {
|
||||
for s in g.sections {
|
||||
for i in s.items {
|
||||
if i.availability == crate::catalog::Availability::Ready {
|
||||
assert!(command(i.command).is_some(), "catalogue item {} ({}) is Ready but not registered", i.label, i.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_labels_follow_platform_conventions() {
|
||||
let s = command("file.save_as").unwrap().shortcut.unwrap();
|
||||
assert_eq!(s.label(true), "⇧⌘S");
|
||||
assert_eq!(s.label(false), "Ctrl+Shift+S");
|
||||
assert_eq!(command("view.read_mode").unwrap().shortcut.unwrap().label(true), "⌃⌘H");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enablement_follows_the_document_state() {
|
||||
let mut s = Session::new();
|
||||
let undo = command("edit.undo").unwrap();
|
||||
let save = command("file.save").unwrap();
|
||||
assert!(!is_enabled(save, &s, None));
|
||||
assert!(is_enabled(command("file.open").unwrap(), &s, None));
|
||||
let id = s.open("a.pdf", None, std::sync::Arc::new(crate::tests::fixture(2)), None).unwrap();
|
||||
assert!(is_enabled(save, &s, Some(id)));
|
||||
assert!(!is_enabled(undo, &s, Some(id)));
|
||||
s.apply(id, crate::Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
assert!(is_enabled(undo, &s, Some(id)));
|
||||
assert_eq!(current_label(undo, &s, Some(id)), "Undo Rotate page");
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,12 @@
|
|||
//! will write. Saving rebases onto the written bytes, so the next save appends only new edits.
|
||||
|
||||
pub mod catalog;
|
||||
pub mod commands;
|
||||
|
||||
pub use printcraft_organize::{SplitBy, split_ranges};
|
||||
|
||||
/// One file produced by a split: (1-based first page, last page, PDF bytes).
|
||||
pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -44,6 +50,10 @@ pub struct Document {
|
|||
pub password: Option<String>,
|
||||
/// `true` when there are edits that have not been saved.
|
||||
pub dirty: bool,
|
||||
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
|
||||
generation: u64,
|
||||
/// The generation last handed out by `autosave_snapshots`.
|
||||
snapshot_generation: u64,
|
||||
/// Why the document cannot be edited (e.g. encryption), if so.
|
||||
pub read_only_reason: Option<String>,
|
||||
editor: Option<Editor>,
|
||||
|
|
@ -63,6 +73,37 @@ impl Document {
|
|||
self.editor.is_some()
|
||||
}
|
||||
|
||||
/// What the opening password allows; `None` when the document is not encrypted.
|
||||
pub fn permissions(&self) -> Option<printcraft_cos::Permissions> {
|
||||
self.editor.as_ref().and_then(|e| e.cos.permissions())
|
||||
}
|
||||
|
||||
/// Page changes (insert, delete, rotate, move, extract) are allowed.
|
||||
pub fn allows_assembly(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.assemble())
|
||||
}
|
||||
|
||||
/// Changes to content and document information are allowed.
|
||||
pub fn allows_modification(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.modify())
|
||||
}
|
||||
|
||||
/// A summary of the document's security for Document Properties ▸ Security.
|
||||
pub fn security_summary(&self) -> Option<SecuritySummary> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let h = editor.cos.security()?;
|
||||
let d = h.dict();
|
||||
let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m);
|
||||
let method = match (d.v, stream) {
|
||||
(1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit",
|
||||
(1..=3, _) | (_, Some(printcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit",
|
||||
(_, Some(printcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit",
|
||||
(_, Some(printcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit",
|
||||
_ => "Attachments only",
|
||||
};
|
||||
Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner, permissions: h.permissions() })
|
||||
}
|
||||
|
||||
/// Current value of a document-information entry (Title, Author, …).
|
||||
pub fn info_value(&self, key: &str) -> Option<String> {
|
||||
self.editor.as_ref().and_then(|e| printcraft_organize::info(&e.cos, key))
|
||||
|
|
@ -78,6 +119,27 @@ fn render_threads() -> usize {
|
|||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
|
||||
}
|
||||
|
||||
/// A document's working file captured for crash recovery.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecoverySnapshot {
|
||||
pub doc: DocId,
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// The snapshot is encrypted (recovering it asks for the password again).
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// Document Properties ▸ Security.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SecuritySummary {
|
||||
/// "AES, 256-bit" etc.
|
||||
pub method: String,
|
||||
/// Opened with the owner password (no restrictions apply).
|
||||
pub owner: bool,
|
||||
pub permissions: printcraft_cos::Permissions,
|
||||
}
|
||||
|
||||
/// Edits that can be applied to a document. Page indices are 0-based.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Edit {
|
||||
|
|
@ -101,6 +163,13 @@ pub enum Edit {
|
|||
key: String,
|
||||
value: String,
|
||||
},
|
||||
/// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`.
|
||||
InsertPagesFrom {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
pages: Option<Vec<usize>>,
|
||||
at: usize,
|
||||
},
|
||||
/// Several edits applied as one undoable step (all or nothing).
|
||||
Batch {
|
||||
label: String,
|
||||
|
|
@ -117,11 +186,37 @@ impl Edit {
|
|||
Edit::MovePages { pages, .. } => plural("Move page", pages.len()),
|
||||
Edit::InsertBlankPage { .. } => "Insert blank page".into(),
|
||||
Edit::SetInfo { key, .. } => format!("Change {key}"),
|
||||
Edit::InsertPagesFrom { name, .. } => format!("Insert pages from {name}"),
|
||||
Edit::Batch { label, .. } => label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||||
fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), EditError> {
|
||||
match edit {
|
||||
Edit::RotatePages { .. }
|
||||
| Edit::DeletePages { .. }
|
||||
| Edit::MovePages { .. }
|
||||
| Edit::InsertBlankPage { .. }
|
||||
| Edit::InsertPagesFrom { .. } => {
|
||||
if p.assemble() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EditError::NotPermitted("page changes"))
|
||||
}
|
||||
}
|
||||
Edit::SetInfo { .. } => {
|
||||
if p.modify() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EditError::NotPermitted("changes to the document"))
|
||||
}
|
||||
}
|
||||
Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an edit on a working copy (the caller discards it on error).
|
||||
fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditError> {
|
||||
match edit {
|
||||
|
|
@ -132,6 +227,14 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditE
|
|||
printcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
|
||||
}
|
||||
Edit::SetInfo { key, value } => printcraft_organize::set_info(doc, key, value)?,
|
||||
Edit::InsertPagesFrom { name, bytes, pages, at } => {
|
||||
let src = open_source(name, bytes)?;
|
||||
let pages = match pages {
|
||||
Some(p) => p.clone(),
|
||||
None => (0..printcraft_organize::page_count(&src)?).collect(),
|
||||
};
|
||||
printcraft_organize::import_pages(doc, &src, &pages, *at)?;
|
||||
}
|
||||
Edit::Batch { edits, .. } => {
|
||||
for e in edits {
|
||||
run_edit(doc, e)?;
|
||||
|
|
@ -141,6 +244,19 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditE
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse another PDF to copy pages from.
|
||||
fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<printcraft_cos::Document, EditError> {
|
||||
match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => {
|
||||
Err(EditError::Source(format!("{name}: its security settings don't allow copying pages")))
|
||||
}
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(printcraft_cos::CosError::NeedsPassword)) => Err(EditError::Source(format!("{name}: it is password-protected"))),
|
||||
Ok(Err(e)) => Err(EditError::Source(format!("{name}: {e}"))),
|
||||
Err(_) => Err(EditError::Source(format!("{name}: the file could not be read"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(s: &str, n: usize) -> String {
|
||||
if n == 1 { s.to_string() } else { format!("{s}s") }
|
||||
}
|
||||
|
|
@ -151,12 +267,16 @@ pub enum EditError {
|
|||
NoDocument,
|
||||
#[error("this document can't be edited: {0}")]
|
||||
ReadOnly(String),
|
||||
#[error("the document's security settings don't allow {0}; open it with the owner password to make this change")]
|
||||
NotPermitted(&'static str),
|
||||
#[error("{0}")]
|
||||
Organize(#[from] printcraft_organize::OrganizeError),
|
||||
#[error("the edited document could not be written: {0}")]
|
||||
Write(String),
|
||||
#[error("the edited document could not be reopened: {0}")]
|
||||
Reopen(String),
|
||||
#[error("couldn't use {0}")]
|
||||
Source(String),
|
||||
#[error("nothing to undo")]
|
||||
NothingToUndo,
|
||||
#[error("nothing to redo")]
|
||||
|
|
@ -196,10 +316,24 @@ impl Session {
|
|||
///
|
||||
/// `password` is tried as either the user or owner password when the file is encrypted.
|
||||
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||||
let info = inspect(bytes.clone(), password)?;
|
||||
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
|
||||
let cos = std::panic::catch_unwind(|| printcraft_cos::Document::open_with_password(bytes.clone(), password));
|
||||
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
|
||||
// files, so give it the user password that owner authentication recovers.
|
||||
let (info, render_password) = match inspect(bytes.clone(), password) {
|
||||
Ok(info) => (info, password.map(str::to_owned)),
|
||||
Err(OpenError::WrongPassword) => {
|
||||
let user = match &cos {
|
||||
Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()),
|
||||
_ => None,
|
||||
};
|
||||
let user: String = user.ok_or(OpenError::WrongPassword)?.iter().map(|b| char::from(*b)).collect();
|
||||
(inspect(bytes.clone(), Some(&user))?, Some(user))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
|
||||
let (editor, read_only_reason) = match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) {
|
||||
let (editor, read_only_reason) = match cos {
|
||||
Ok(Ok(cos)) => (Some(Editor { cos, undo: Vec::new(), redo: Vec::new() }), None),
|
||||
Ok(Err(e)) => (None, Some(e.to_string())),
|
||||
Err(_) => (None, Some("the document structure could not be read for editing".into())),
|
||||
|
|
@ -213,8 +347,10 @@ impl Session {
|
|||
bytes,
|
||||
info,
|
||||
renderer,
|
||||
password: password.map(str::to_owned),
|
||||
password: render_password,
|
||||
dirty: false,
|
||||
generation: 0,
|
||||
snapshot_generation: 0,
|
||||
read_only_reason,
|
||||
editor,
|
||||
config,
|
||||
|
|
@ -231,6 +367,9 @@ impl Session {
|
|||
let doc = self.doc_mut(id)?;
|
||||
let reason = doc.read_only_reason.clone().unwrap_or_default();
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?;
|
||||
if let Some(p) = editor.cos.permissions() {
|
||||
check_permission(&edit, &p)?;
|
||||
}
|
||||
let mut next = editor.cos.clone();
|
||||
run_edit(&mut next, &edit)?;
|
||||
let previous = std::mem::replace(&mut editor.cos, next);
|
||||
|
|
@ -250,6 +389,7 @@ impl Session {
|
|||
return Err(e);
|
||||
}
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +401,7 @@ impl Session {
|
|||
editor.redo.push((label.clone(), current));
|
||||
Self::refresh(doc)?;
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +413,7 @@ impl Session {
|
|||
editor.undo.push((label.clone(), current));
|
||||
Self::refresh(doc)?;
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
|
|
@ -329,9 +471,93 @@ impl Session {
|
|||
doc.path = Some(p);
|
||||
}
|
||||
doc.dirty = false;
|
||||
doc.generation += 1;
|
||||
Self::refresh(doc)
|
||||
}
|
||||
|
||||
/// Combine whole files, in order, into new PDF bytes (one bookmark per file).
|
||||
pub fn combine(&self, sources: &[(String, Arc<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
|
||||
let named: Vec<(&str, &printcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect();
|
||||
let out = printcraft_organize::combine(&named)?;
|
||||
self.write_new(&out)
|
||||
}
|
||||
|
||||
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).
|
||||
pub fn extract(&self, id: DocId, pages: &[usize]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let src = self.cos(id)?;
|
||||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||||
return Err(EditError::NotPermitted("extracting pages"));
|
||||
}
|
||||
let out = printcraft_organize::extract_pages(src, pages)?;
|
||||
self.write_new(&out)
|
||||
}
|
||||
|
||||
/// Split the document into several new PDFs.
|
||||
pub fn split(&self, id: DocId, by: &printcraft_organize::SplitBy) -> Result<Vec<SplitPart>, EditError> {
|
||||
let src = self.cos(id)?;
|
||||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||||
return Err(EditError::NotPermitted("splitting the document"));
|
||||
}
|
||||
let n = printcraft_organize::page_count(src)?;
|
||||
printcraft_organize::split_ranges(n, by)
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let doc = printcraft_organize::extract_pages(src, &r.clone().collect::<Vec<_>>())?;
|
||||
Ok((r.start + 1, r.end, self.write_new(&doc)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Open freshly created bytes (combine / extract) as a new, unsaved document.
|
||||
pub fn open_new(&mut self, name: impl Into<String>, bytes: Arc<Vec<u8>>) -> Result<DocId, OpenError> {
|
||||
let id = self.open(name, None, bytes, None)?;
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
d.dirty = true;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn cos(&self, id: DocId) -> Result<&printcraft_cos::Document, EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn write_new(&self, doc: &printcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
}
|
||||
|
||||
/// Documents with unsaved changes made since the last call: their current working file,
|
||||
/// for crash recovery. Encrypted documents stay encrypted in the snapshot.
|
||||
pub fn autosave_snapshots(&mut self) -> Vec<RecoverySnapshot> {
|
||||
let mut out = Vec::new();
|
||||
for d in &mut self.docs {
|
||||
if d.dirty && d.generation != d.snapshot_generation {
|
||||
d.snapshot_generation = d.generation;
|
||||
out.push(RecoverySnapshot {
|
||||
doc: d.id,
|
||||
name: d.name.clone(),
|
||||
path: d.path.clone(),
|
||||
bytes: d.bytes.clone(),
|
||||
encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mark a document opened from a recovery file: it has unsaved changes and belongs at
|
||||
/// `path` (where Save writes), as when the session ended.
|
||||
pub fn mark_recovered(&mut self, id: DocId, path: Option<String>) {
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
d.path = path;
|
||||
d.dirty = true;
|
||||
d.generation += 1;
|
||||
d.snapshot_generation = d.generation; // its bytes are already in the recovery store
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed;
|
||||
/// callers must drop cached rasters and text for the document.
|
||||
pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ fn session_with(n: usize) -> (Session, DocId) {
|
|||
|
||||
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let config = printcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config);
|
||||
(0..doc.info.pages.len())
|
||||
.map(|p| {
|
||||
let r = r.render(RenderRequestFor::text(p));
|
||||
|
|
@ -189,3 +190,143 @@ fn insert_blank_between_pages_keeps_both_neighbours() {
|
|||
s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap();
|
||||
assert_eq!(page_texts(&s, id), ["Page 1", "", "Page 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_extract_split_and_insert_from_file() {
|
||||
let (mut s, id) = session_with(3);
|
||||
let other = Arc::new(fixture(2));
|
||||
// Combine: the first file then the second.
|
||||
let combined = s.combine(&[("a.pdf".into(), Arc::new(fixture(3))), ("b.pdf".into(), other.clone())]).unwrap();
|
||||
let cid = s.open_new("Combined.pdf", combined).unwrap();
|
||||
assert_eq!(page_texts(&s, cid), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]);
|
||||
assert!(s.get(cid).unwrap().dirty, "a new document starts unsaved");
|
||||
assert_eq!(s.get(cid).unwrap().info.outline.len(), 2, "one bookmark per file");
|
||||
// Extract pages 3 and 1 into a new document.
|
||||
let ex = s.extract(id, &[2, 0]).unwrap();
|
||||
let eid = s.open_new("Extract.pdf", ex).unwrap();
|
||||
assert_eq!(page_texts(&s, eid), ["Page 3", "Page 1"]);
|
||||
// Split every 2 pages.
|
||||
let parts = s.split(id, &printcraft_organize::SplitBy::PageCount(2)).unwrap();
|
||||
assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::<Vec<_>>(), [(1, 2), (3, 3)]);
|
||||
// Insert pages from a file, undoably, into the open document.
|
||||
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap();
|
||||
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
|
||||
assert_eq!(s.undo(id).unwrap(), "Insert pages from b.pdf");
|
||||
assert_eq!(page_texts(&s, id).len(), 3);
|
||||
// Garbage sources fail cleanly.
|
||||
let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 });
|
||||
assert!(matches!(bad, Err(EditError::Source(_))));
|
||||
}
|
||||
|
||||
fn protected(user: &str, owner: &str, permissions: i32) -> Arc<Vec<u8>> {
|
||||
let mut doc = printcraft_cos::Document::open(Arc::new(fixture(2))).unwrap();
|
||||
doc.set_encryption(&printcraft_cos::NewEncryption {
|
||||
algorithm: printcraft_cos::Algorithm::Aes256,
|
||||
user_password: user,
|
||||
owner_password: owner,
|
||||
permissions,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
Arc::new(printcraft_cos::write_full(&doc, &Default::default()).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_open_edit_and_save_encrypted() {
|
||||
let bytes = protected("pw", "owner", -1);
|
||||
let mut s = Session::new();
|
||||
assert!(s.open("x.pdf", None, bytes.clone(), None).is_err(), "needs a password");
|
||||
let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap();
|
||||
assert!(s.get(id).unwrap().editable(), "{:?}", s.get(id).unwrap().read_only_reason);
|
||||
assert_eq!(s.get(id).unwrap().security_summary().unwrap().method, "AES, 256-bit");
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert_eq!(&saved[..bytes.len()], &bytes[..], "incremental");
|
||||
assert!(printcraft_cos::Document::open(saved.clone()).is_err(), "still protected after saving");
|
||||
let mut s2 = Session::new();
|
||||
let id2 = s2.open("x.pdf", None, saved, Some("pw")).unwrap();
|
||||
assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_documents_refuse_changes_unless_opened_by_the_owner() {
|
||||
let bytes = protected("", "owner", 0b0100); // print only, no password to open
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert!(!d.allows_assembly() && !d.allows_modification());
|
||||
let summary = d.security_summary().unwrap();
|
||||
assert!(!summary.owner && summary.permissions.print() && !summary.permissions.copy());
|
||||
assert_eq!(s.apply(id, Edit::DeletePages { pages: vec![0] }), Err(EditError::NotPermitted("page changes")));
|
||||
assert_eq!(s.apply(id, Edit::SetInfo { key: "Title".into(), value: "x".into() }), Err(EditError::NotPermitted("changes to the document")));
|
||||
assert!(matches!(s.extract(id, &[0]), Err(EditError::NotPermitted(_))));
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
// The owner password lifts the restrictions.
|
||||
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
|
||||
assert!(s.get(oid).unwrap().allows_assembly());
|
||||
s.apply(oid, Edit::DeletePages { pages: vec![0] }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_protected_files_is_refused_clearly() {
|
||||
let s = Session::new();
|
||||
let err = s.combine(&[("a.pdf".into(), protected("pw", "o", -1)), ("b.pdf".into(), Arc::new(fixture(1)))]).unwrap_err();
|
||||
assert_eq!(err, EditError::Source("a.pdf: it is password-protected".into()));
|
||||
let err = s.combine(&[("c.pdf".into(), protected("", "o", 0b0100))]).unwrap_err();
|
||||
assert!(matches!(err, EditError::Source(m) if m.contains("don't allow copying pages")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_password_of_older_revisions_opens_the_viewer_too() {
|
||||
for alg in [printcraft_cos::Algorithm::Rc4_128, printcraft_cos::Algorithm::Aes128] {
|
||||
let mut doc = printcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
|
||||
doc.set_encryption(&printcraft_cos::NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: 0,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = Arc::new(printcraft_cos::write_full(&doc, &Default::default()).unwrap());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("x.pdf", None, bytes, Some("o")).unwrap_or_else(|e| panic!("{alg:?}: {e}"));
|
||||
let d = s.get(id).unwrap();
|
||||
assert!(d.security_summary().unwrap().owner && d.allows_modification(), "{alg:?}");
|
||||
assert_eq!(page_texts(&s, id), ["Page 1"], "{alg:?}: the renderer reads it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_snapshots_only_changed_documents() {
|
||||
let (mut s, id) = session_with(2);
|
||||
assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted");
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
let snaps = s.autosave_snapshots();
|
||||
assert_eq!(snaps.len(), 1);
|
||||
assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file");
|
||||
assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot");
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.autosave_snapshots().len(), 1, "undo is a change too");
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
s.mark_saved(id, saved, None).unwrap();
|
||||
assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_documents_reopen_unsaved_at_their_original_path() {
|
||||
let (mut s, id) = session_with(2);
|
||||
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
|
||||
let snap = s.autosave_snapshots().remove(0);
|
||||
// A new session after a crash.
|
||||
let mut s2 = Session::new();
|
||||
let rid = s2.open(snap.name.clone(), None, snap.bytes.clone(), None).unwrap();
|
||||
s2.mark_recovered(rid, Some("/docs/report.pdf".into()));
|
||||
let d = s2.get(rid).unwrap();
|
||||
assert!(d.dirty);
|
||||
assert_eq!(d.path.as_deref(), Some("/docs/report.pdf"));
|
||||
assert_eq!(d.info.pages.len(), 1, "the edit survived");
|
||||
assert!(s2.autosave_snapshots().is_empty(), "already in the recovery store");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,636 @@
|
|||
//! Copying pages between documents: the basis of Combine Files, Insert Pages from File,
|
||||
//! Extract Pages and Split.
|
||||
//!
|
||||
//! A page is deep-copied with everything it references (content streams, resources, fonts,
|
||||
//! images, annotations), renumbered into the destination. Objects shared between the copied
|
||||
//! pages (a font used on every page) are copied once. The copy never follows `/Parent` from a
|
||||
//! page, so the source page tree does not come along. It rewires what points *back* into the
|
||||
//! document:
|
||||
//! - an annotation's `/P` points at its new page;
|
||||
//! - link and GoTo destinations that target a copied page point at the copy. Links to pages that
|
||||
//! were not copied are dropped, so they don't dangle;
|
||||
//! - form-field widgets keep their field hierarchy, and the top-level fields are registered in the
|
||||
//! destination `/AcroForm`;
|
||||
//! - structure-tree links (`/StructParent(s)`) and article beads (`/B`) are removed, because the
|
||||
//! source structure tree is not copied;
|
||||
//! - named destinations are resolved against the source's name tree and become explicit ones;
|
||||
//! - layers (optional content groups) used by the pages are registered in the destination's
|
||||
//! `/OCProperties`, keeping their default on/off state.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use printcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk};
|
||||
|
||||
/// Keys that must not be followed when copying: they point back up or across the source
|
||||
/// document, and following them would drag in unrelated objects.
|
||||
const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"];
|
||||
const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"];
|
||||
|
||||
struct Copier<'a> {
|
||||
src: &'a Document,
|
||||
map: HashMap<ObjRef, ObjRef>,
|
||||
/// Source page → destination page, for rewriting `/P` and destinations.
|
||||
pages: HashMap<ObjRef, ObjRef>,
|
||||
/// Destination objects that are annotation dictionaries (fixed up after copying).
|
||||
annots: Vec<ObjRef>,
|
||||
/// Top-level form fields reached through widgets.
|
||||
fields: Vec<ObjRef>,
|
||||
/// Optional content groups copied: (source, destination).
|
||||
ocgs: Vec<(ObjRef, ObjRef)>,
|
||||
}
|
||||
|
||||
impl Copier<'_> {
|
||||
/// Copy an indirect object (once), returning its destination reference.
|
||||
fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> ObjRef {
|
||||
if let Some(d) = self.map.get(&r) {
|
||||
return *d;
|
||||
}
|
||||
// Reserve the number first so cycles (field ↔ widget) terminate.
|
||||
let new = dst.add(Object::Null);
|
||||
self.map.insert(r, new);
|
||||
let obj = self.src.get(r);
|
||||
if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) {
|
||||
self.ocgs.push((r, new));
|
||||
}
|
||||
let copied = self.copy_value(dst, &obj, Some(new));
|
||||
dst.set(new, copied);
|
||||
new
|
||||
}
|
||||
|
||||
fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Object {
|
||||
match o {
|
||||
Object::Ref(r) => Object::Ref(self.copy_ref(dst, *r)),
|
||||
Object::Array(a) => Object::Array(a.iter().map(|x| self.copy_value(dst, x, None)).collect()),
|
||||
Object::Dict(d) => Object::Dict(self.copy_dict(dst, d, this)),
|
||||
Object::Stream(s) => {
|
||||
let mut s = s.clone();
|
||||
s.dict = self.copy_dict(dst, &s.dict, None);
|
||||
Object::Stream(s)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option<ObjRef>) -> Dict {
|
||||
let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type"));
|
||||
let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids"));
|
||||
let mut out = Dict::new();
|
||||
for (k, v) in d.iter() {
|
||||
if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) {
|
||||
continue;
|
||||
}
|
||||
// Destinations are rewritten afterwards (they may target pages copied later).
|
||||
if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") {
|
||||
out.set(k.clone(), v.clone());
|
||||
continue;
|
||||
}
|
||||
out.set(k.clone(), self.copy_value(dst, v, None));
|
||||
}
|
||||
if let Some(this) = this {
|
||||
if is_annot {
|
||||
self.annots.push(this);
|
||||
}
|
||||
// A field (or widget) without a parent is a top-level field.
|
||||
if is_field && !d.contains(b"Parent") {
|
||||
self.fields.push(this);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map a source destination (explicit array or reference to one) to the destination
|
||||
/// document; `None` if it targets a page that was not copied.
|
||||
fn map_dest(&self, dest: &Object) -> Option<Object> {
|
||||
self.map_dest_depth(dest, 0)
|
||||
}
|
||||
|
||||
fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option<Object> {
|
||||
if depth > 4 {
|
||||
return None;
|
||||
}
|
||||
match dest {
|
||||
Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1),
|
||||
Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1),
|
||||
Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1),
|
||||
Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1),
|
||||
Object::Array(a) => {
|
||||
let page = a.first()?.as_ref()?;
|
||||
let new_page = *self.pages.get(&page)?;
|
||||
let mut a = a.clone();
|
||||
a[0] = Object::Ref(new_page);
|
||||
Some(Object::Array(a))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations.
|
||||
fn fix_annotations(&self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
for &a in &self.annots {
|
||||
let src_dict = dst.get(a).as_dict().cloned();
|
||||
let Some(d) = src_dict else { continue };
|
||||
let mut d = d;
|
||||
if let Some(p) = annot_pages.get(&a) {
|
||||
d.set(b"P".to_vec(), Object::Ref(*p));
|
||||
}
|
||||
if let Some(dest) = d.get(b"Dest").cloned() {
|
||||
let resolved = self.src.resolve(&dest);
|
||||
match self.map_dest(&resolved) {
|
||||
Some(nd) => d.set(b"Dest".to_vec(), nd),
|
||||
None => {
|
||||
d.remove(b"Dest");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(action) = d.get(b"A").cloned() {
|
||||
let action = self.src.resolve(&action);
|
||||
if let Some(ad) = action.as_dict() {
|
||||
if ad.name(b"S") == Some(b"GoTo") {
|
||||
let target = ad.get(b"D").map(|x| self.src.resolve(x));
|
||||
match target.and_then(|t| self.map_dest(&t)) {
|
||||
Some(nd) => {
|
||||
let mut na = Dict::new();
|
||||
na.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
na.set(b"D".to_vec(), nd);
|
||||
d.set(b"A".to_vec(), Object::Dict(na));
|
||||
}
|
||||
None => {
|
||||
d.remove(b"A");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Other actions (URI, Launch, JavaScript…) carry no page references.
|
||||
let mut c = Copier {
|
||||
src: self.src,
|
||||
map: self.map.clone(),
|
||||
pages: HashMap::new(),
|
||||
annots: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
ocgs: Vec::new(),
|
||||
};
|
||||
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None);
|
||||
d.set(b"A".to_vec(), copied);
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.set(a, Object::Dict(d));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
|
||||
/// position `at`. Returns the new page references in order.
|
||||
pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Vec<ObjRef>, OrganizeError> {
|
||||
import_pages_mapped(dst, src, src_pages, at).map(|(pages, _)| pages)
|
||||
}
|
||||
|
||||
/// `import_pages`, also returning the source-page → destination-page map.
|
||||
fn import_pages_mapped(
|
||||
dst: &mut Document,
|
||||
src: &Document,
|
||||
src_pages: &[usize],
|
||||
at: usize,
|
||||
) -> Result<(Vec<ObjRef>, HashMap<ObjRef, ObjRef>), OrganizeError> {
|
||||
let source = walk(src)?;
|
||||
if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) {
|
||||
return Err(OrganizeError::NoSuchPage(*bad));
|
||||
}
|
||||
let mut existing = walk(dst)?;
|
||||
let root = pages_root(dst)?;
|
||||
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
// Allocate every destination page first, so destinations between copied pages resolve.
|
||||
let targets: Vec<(ObjRef, ObjRef, Dict)> = src_pages
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let (page, inherited) = &source[i];
|
||||
let new = match copier.pages.get(page) {
|
||||
Some(n) => *n, // the same page requested twice gets a second copy below
|
||||
None => dst.add(Object::Null),
|
||||
};
|
||||
copier.pages.insert(*page, new);
|
||||
(*page, new, inherited.clone())
|
||||
})
|
||||
.collect();
|
||||
let mut annot_pages = HashMap::new();
|
||||
let mut new_pages = Vec::new();
|
||||
let mut done: HashMap<ObjRef, ObjRef> = HashMap::new();
|
||||
for (page, new, inherited) in targets {
|
||||
// A page listed twice: make a second, independent page object sharing resources.
|
||||
let new = if done.contains_key(&page) { dst.add(Object::Null) } else { new };
|
||||
let src_dict = src.get(page).as_dict().cloned().unwrap_or_default();
|
||||
let mut d = Dict::new();
|
||||
for (k, v) in src_dict.iter() {
|
||||
if SKIP_ON_PAGE.contains(&k.as_slice()) {
|
||||
continue;
|
||||
}
|
||||
if k.as_slice() == b"Annots" {
|
||||
let list = src.resolve(v).as_array().cloned().unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
// Direct (inline) annotation dictionaries become indirect objects, so every
|
||||
// copied annotation can be fixed up the same way.
|
||||
let r = match a {
|
||||
Object::Ref(r) => copier.copy_ref(dst, r),
|
||||
Object::Dict(_) => {
|
||||
let r = dst.add(Object::Null);
|
||||
let c = copier.copy_value(dst, &a, Some(r));
|
||||
dst.set(r, c);
|
||||
r
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
annot_pages.insert(r, new);
|
||||
out.push(Object::Ref(r));
|
||||
}
|
||||
d.set(b"Annots".to_vec(), Object::Array(out));
|
||||
continue;
|
||||
}
|
||||
d.set(k.clone(), copier.copy_value(dst, v, None));
|
||||
}
|
||||
for k in INHERITABLE {
|
||||
if !d.contains(k)
|
||||
&& let Some(v) = inherited.get(k)
|
||||
{
|
||||
let v = copier.copy_value(dst, v, None);
|
||||
d.set(k.to_vec(), v);
|
||||
}
|
||||
}
|
||||
d.set(b"Type".to_vec(), Object::name("Page"));
|
||||
d.set(b"Parent".to_vec(), Object::Ref(root));
|
||||
dst.set(new, Object::Dict(d));
|
||||
done.insert(page, new);
|
||||
new_pages.push(new);
|
||||
}
|
||||
copier.fix_annotations(dst, &annot_pages)?;
|
||||
register_fields(dst, &copier.fields)?;
|
||||
register_layers(dst, src, &copier.ocgs)?;
|
||||
let at = at.min(existing.len());
|
||||
let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect();
|
||||
existing.splice(at..at, inserted);
|
||||
rebuild(dst, &existing)?;
|
||||
Ok((new_pages, copier.pages))
|
||||
}
|
||||
|
||||
/// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree).
|
||||
fn named_dest(src: &Document, name: &[u8]) -> Option<Object> {
|
||||
let catalog = src.get(src.root()?).as_dict().cloned()?;
|
||||
if let Some(dests) = catalog.get(b"Dests").map(|d| src.resolve(d))
|
||||
&& let Some(v) = dests.as_dict().and_then(|d| d.get(name))
|
||||
{
|
||||
return Some(src.resolve(v).as_ref().clone());
|
||||
}
|
||||
let names = src.resolve(catalog.get(b"Names")?);
|
||||
let tree = names.as_dict()?.get(b"Dests")?.clone();
|
||||
name_tree_lookup(src, &tree, name, 0)
|
||||
}
|
||||
|
||||
fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Option<Object> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
}
|
||||
let node = src.resolve(node);
|
||||
let d = node.as_dict()?;
|
||||
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
|
||||
for pair in names.chunks(2) {
|
||||
if let [k, v] = pair
|
||||
&& src.resolve(k).as_string().is_some_and(|s| s.bytes == key)
|
||||
{
|
||||
return Some(src.resolve(v).as_ref().clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let kids = d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1))
|
||||
}
|
||||
|
||||
/// All (key, value) pairs of a name tree, in order.
|
||||
fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec<u8>, Object)>) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
let node = src.resolve(node);
|
||||
let Some(d) = node.as_dict() else { return };
|
||||
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
|
||||
for pair in names.chunks(2) {
|
||||
if let [k, v] = pair
|
||||
&& let Some(k) = src.resolve(k).as_string()
|
||||
{
|
||||
out.push((k.bytes.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default() {
|
||||
name_tree_entries(src, &k, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register copied optional content groups in the destination's `/OCProperties`, keeping each
|
||||
/// layer's default visibility from the source.
|
||||
fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> {
|
||||
if ocgs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let src_off: Vec<Object> = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d)))
|
||||
.and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?;
|
||||
let (props_ref, mut props) = match catalog.get(b"OCProperties") {
|
||||
Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()),
|
||||
Some(Object::Dict(d)) => (None, d.clone()),
|
||||
_ => (None, Dict::new()),
|
||||
};
|
||||
let list = |d: &Dict, k: &[u8], doc: &Document| d.get(k).map(|v| doc.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
let mut all = list(&props, b"OCGs", dst);
|
||||
let mut config = props.get(b"D").map(|d| dst.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
|
||||
let (mut on, mut off, mut order) = (list(&config, b"ON", dst), list(&config, b"OFF", dst), list(&config, b"Order", dst));
|
||||
for (from, to) in ocgs {
|
||||
let r = Object::Ref(*to);
|
||||
if all.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
all.push(r.clone());
|
||||
order.push(r.clone());
|
||||
if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) }
|
||||
}
|
||||
props.set(b"OCGs".to_vec(), Object::Array(all));
|
||||
config.set(b"ON".to_vec(), Object::Array(on));
|
||||
config.set(b"OFF".to_vec(), Object::Array(off));
|
||||
config.set(b"Order".to_vec(), Object::Array(order));
|
||||
props.set(b"D".to_vec(), Object::Dict(config));
|
||||
match props_ref {
|
||||
Some(r) => dst.set(r, Object::Dict(props)),
|
||||
None => dst.update_dict(root, |c| c.set(b"OCProperties".to_vec(), Object::Dict(props)))?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed).
|
||||
fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> {
|
||||
if fields.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?;
|
||||
let (form_ref, mut form) = match catalog.get(b"AcroForm") {
|
||||
Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()),
|
||||
Some(Object::Dict(d)) => (None, d.clone()),
|
||||
_ => (None, Dict::new()),
|
||||
};
|
||||
let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
for f in fields {
|
||||
if !list.contains(&Object::Ref(*f)) {
|
||||
list.push(Object::Ref(*f));
|
||||
}
|
||||
}
|
||||
form.set(b"Fields".to_vec(), Object::Array(list));
|
||||
match form_ref {
|
||||
Some(r) => dst.set(r, Object::Dict(form)),
|
||||
None => {
|
||||
let r = dst.add(form);
|
||||
dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A new document containing copies of `pages` from `src` (Extract Pages / Split).
|
||||
/// Document information (title, author…) is carried over.
|
||||
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
|
||||
let mut out = Document::new_empty();
|
||||
import_pages(&mut out, src, pages, 0)?;
|
||||
for key in crate::INFO_KEYS {
|
||||
if let Some(v) = crate::info(src, key) {
|
||||
crate::set_info(&mut out, key, &v)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How to split a document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SplitBy {
|
||||
/// Every `n` pages.
|
||||
PageCount(usize),
|
||||
/// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end).
|
||||
Before(Vec<usize>),
|
||||
}
|
||||
|
||||
/// The page ranges (0-based, end-exclusive) a split produces.
|
||||
pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec<std::ops::Range<usize>> {
|
||||
let mut cuts: Vec<usize> = match by {
|
||||
SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(),
|
||||
SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(),
|
||||
};
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
let mut ranges = Vec::new();
|
||||
let mut start = 0;
|
||||
for c in cuts.into_iter().chain(std::iter::once(page_count)) {
|
||||
if c > start {
|
||||
ranges.push(start..c);
|
||||
start = c;
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Split a document into several new documents.
|
||||
pub fn split(src: &Document, by: &SplitBy) -> Result<Vec<Document>, OrganizeError> {
|
||||
let n = crate::page_count(src)?;
|
||||
split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::<Vec<_>>())).collect()
|
||||
}
|
||||
|
||||
/// Combine whole documents, in order, into a new document. Each source gets a top-level
|
||||
/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files.
|
||||
/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level
|
||||
/// attachments are carried over.
|
||||
pub fn combine(sources: &[(&str, &Document)]) -> Result<Document, OrganizeError> {
|
||||
let mut out = Document::new_empty();
|
||||
let mut marks = Vec::new();
|
||||
let mut attachments = Vec::new();
|
||||
for (title, src) in sources {
|
||||
let n = crate::page_count(src)?;
|
||||
let at = crate::page_count(&out)?;
|
||||
let (new, page_map) = import_pages_mapped(&mut out, src, &(0..n).collect::<Vec<_>>(), at)?;
|
||||
if let Some(first) = new.first() {
|
||||
marks.push((title.to_string(), *first, *src, page_map));
|
||||
}
|
||||
collect_attachments(&mut out, src, &mut attachments);
|
||||
}
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`.
|
||||
fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8>, Object)>) {
|
||||
let Some(tree) = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"Names").map(|n| src.resolve(n)))
|
||||
.and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
name_tree_entries(src, &tree, 0, &mut entries);
|
||||
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
for (k, v) in entries {
|
||||
let v = copier.copy_value(dst, &v, None);
|
||||
out.push((k, v));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"…
|
||||
fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (k, _) in entries.iter_mut() {
|
||||
let base = k.clone();
|
||||
let mut n = 2;
|
||||
while !seen.insert(k.clone()) {
|
||||
*k = [base.as_slice(), format!(" ({n})").as_bytes()].concat();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut names = Vec::new();
|
||||
for (k, v) in entries {
|
||||
names.push(Object::String(printcraft_cos::PdfString::literal(k)));
|
||||
names.push(v);
|
||||
}
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Names".to_vec(), Object::Array(names));
|
||||
let tree = dst.add(tree);
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
dst.update_dict(root, |c| {
|
||||
let mut n = match c.get(b"Names") {
|
||||
Some(Object::Dict(d)) => d.clone(),
|
||||
_ => Dict::new(),
|
||||
};
|
||||
n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree));
|
||||
c.set(b"Names".to_vec(), Object::Dict(n));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Mark<'a> = (String, ObjRef, &'a Document, HashMap<ObjRef, ObjRef>);
|
||||
|
||||
/// Replace the outline with one bookmark per source (title → its first page), each holding a
|
||||
/// collapsed copy of that source's own bookmarks with destinations mapped to the copied pages.
|
||||
fn add_outline(doc: &mut Document, marks: &[Mark<'_>]) -> Result<(), OrganizeError> {
|
||||
if marks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let outlines = doc.add(Object::Null);
|
||||
let items: Vec<ObjRef> = marks.iter().map(|_| doc.add(Object::Null)).collect();
|
||||
for (i, ((title, page, src, page_map), r)) in marks.iter().zip(&items).enumerate() {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Title".to_vec(), Object::String(printcraft_cos::PdfString::text(title)));
|
||||
d.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||||
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(*page), Object::name("Fit")]));
|
||||
if i > 0 {
|
||||
d.set(b"Prev".to_vec(), Object::Ref(items[i - 1]));
|
||||
}
|
||||
if let Some(next) = items.get(i + 1) {
|
||||
d.set(b"Next".to_vec(), Object::Ref(*next));
|
||||
}
|
||||
let first = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.reference(b"First")));
|
||||
let mapper = Copier { src, map: HashMap::new(), pages: page_map.clone(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let mut budget = 10_000usize;
|
||||
if let Some((f, l, n)) = copy_outline_level(doc, &mapper, first, *r, 0, &mut budget) {
|
||||
d.set(b"First".to_vec(), Object::Ref(f));
|
||||
d.set(b"Last".to_vec(), Object::Ref(l));
|
||||
d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed
|
||||
}
|
||||
doc.set(*r, Object::Dict(d));
|
||||
}
|
||||
let mut o = Dict::new();
|
||||
o.set(b"Type".to_vec(), Object::name("Outlines"));
|
||||
o.set(b"First".to_vec(), Object::Ref(items[0]));
|
||||
o.set(b"Last".to_vec(), Object::Ref(*items.last().expect("non-empty")));
|
||||
o.set(b"Count".to_vec(), Object::Int(items.len() as i64));
|
||||
doc.set(outlines, Object::Dict(o));
|
||||
let root = doc.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"Outlines".to_vec(), Object::Ref(outlines));
|
||||
c.set(b"PageMode".to_vec(), Object::name("UseOutlines"));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy one level of a source outline (siblings from `first`) under `parent`, recursing into
|
||||
/// children. Returns (first, last, visible count). Cycle- and size-safe.
|
||||
fn copy_outline_level(
|
||||
dst: &mut Document,
|
||||
m: &Copier<'_>,
|
||||
first: Option<ObjRef>,
|
||||
parent: ObjRef,
|
||||
depth: u8,
|
||||
budget: &mut usize,
|
||||
) -> Option<(ObjRef, ObjRef, usize)> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut made: Vec<ObjRef> = Vec::new();
|
||||
let mut total = 0;
|
||||
let mut cur = first;
|
||||
while let Some(r) = cur {
|
||||
if !seen.insert(r) || *budget == 0 {
|
||||
break;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Some(item) = m.src.get(r).as_dict().cloned() else { break };
|
||||
cur = item.reference(b"Next");
|
||||
let new = dst.add(Object::Null);
|
||||
let mut d = Dict::new();
|
||||
if let Some(t) = item.get(b"Title").map(|t| m.src.resolve(t)) {
|
||||
d.set(b"Title".to_vec(), t.as_ref().clone());
|
||||
}
|
||||
for k in [&b"C"[..], b"F"] {
|
||||
if let Some(v) = item.get(k) {
|
||||
d.set(k.to_vec(), m.src.resolve(v).as_ref().clone());
|
||||
}
|
||||
}
|
||||
let target = item.get(b"Dest").cloned().or_else(|| {
|
||||
let a = m.src.resolve(item.get(b"A")?);
|
||||
let a = a.as_dict()?;
|
||||
(a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten()
|
||||
});
|
||||
if let Some(dest) = target.and_then(|t| m.map_dest(&t)) {
|
||||
d.set(b"Dest".to_vec(), dest);
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
if let Some(prev) = made.last() {
|
||||
d.set(b"Prev".to_vec(), Object::Ref(*prev));
|
||||
dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new))).ok()?;
|
||||
}
|
||||
if let Some((f, l, n)) = copy_outline_level(dst, m, item.reference(b"First"), new, depth + 1, budget) {
|
||||
d.set(b"First".to_vec(), Object::Ref(f));
|
||||
d.set(b"Last".to_vec(), Object::Ref(l));
|
||||
// Keep the source's open/closed state.
|
||||
let open = item.int(b"Count").is_some_and(|c| c > 0);
|
||||
d.set(b"Count".to_vec(), Object::Int(if open { n as i64 } else { -(n as i64) }));
|
||||
}
|
||||
dst.set(new, Object::Dict(d));
|
||||
made.push(new);
|
||||
total += 1;
|
||||
}
|
||||
Some((*made.first()?, *made.last()?, total))
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
mod import;
|
||||
|
||||
pub use import::{SplitBy, combine, extract_pages, import_pages, split, split_ranges};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum OrganizeError {
|
||||
#[error("the document has no page tree")]
|
||||
|
|
|
|||
|
|
@ -185,3 +185,273 @@ fn full_save_drops_unreachable_objects() {
|
|||
assert_eq!(info(&full, "Title").as_deref(), Some("Original"));
|
||||
assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1);
|
||||
}
|
||||
|
||||
// ── Combine / extract / split ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a classic-xref PDF from object bodies (object i+1 = bodies[i]).
|
||||
fn build(bodies: &[String], trailer: &str) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, b) in bodies.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn body(text: &str) -> String {
|
||||
let s = format!("BT /F1 12 Tf 20 20 Td ({text}) Tj ET");
|
||||
format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len())
|
||||
}
|
||||
|
||||
/// Document A: pages A1–A3 sharing one font. A1 links to A3 (GoTo action), A2 links to A1
|
||||
/// (/Dest) and carries a text field widget; A3 has a note with a popup (a reference cycle).
|
||||
fn doc_a() -> Document {
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [12 0 R] >> >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> /Annots [10 0 R] >>".into(), // 3
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 9 0 R >> >> /Annots [11 0 R 12 0 R] >>".into(), // 4
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Resources << /Font << /F1 9 0 R >> >> /Annots [13 0 R] /StructParents 4 >>".into(), // 5
|
||||
body("A1"), // 6
|
||||
body("A2"), // 7
|
||||
body("A3"), // 8
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), // 9
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] /P 3 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(), // 10
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] /P 4 0 R /Dest [3 0 R /XYZ 0 400 0] >>".into(), // 11
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [60 60 200 80] /P 4 0 R >>".into(), // 12
|
||||
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (note) /Popup 14 0 R /P 5 0 R /StructParent 7 >>".into(), // 13
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 13 0 R >>".into(), // 14
|
||||
"<< /Title (Doc A) /Author (Alice) >>".into(), // 15
|
||||
];
|
||||
open(build(&b, "/Root 1 0 R /Info 15 0 R"))
|
||||
}
|
||||
|
||||
/// Document B: pages B1, B2 in a nested tree with MediaBox and Rotate inherited from the root.
|
||||
fn doc_b() -> Document {
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 2 /MediaBox [0 0 500 250] /Rotate 90 >>".into(),
|
||||
"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 3 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
|
||||
"<< /Type /Page /Parent 3 0 R /Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
|
||||
body("B1"),
|
||||
body("B2"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(),
|
||||
];
|
||||
open(build(&b, "/Root 1 0 R"))
|
||||
}
|
||||
|
||||
/// Write in full, reopen with our reader, and require hayro to agree on the page count.
|
||||
fn full_roundtrip(doc: &Document) -> Document {
|
||||
let bytes = write_full(doc, &SaveOptions::default()).expect("writes");
|
||||
let reopened = open(bytes.clone());
|
||||
let hay = hayro_syntax::Pdf::new(bytes).expect("hayro opens");
|
||||
assert_eq!(hay.pages().len(), page_count(&reopened).unwrap());
|
||||
reopened
|
||||
}
|
||||
|
||||
fn page_dict(doc: &Document, i: usize) -> Dict {
|
||||
doc.get(pages(doc).unwrap()[i].obj).as_dict().cloned().unwrap()
|
||||
}
|
||||
|
||||
fn annots(doc: &Document, i: usize) -> Vec<Dict> {
|
||||
let p = page_dict(doc, i);
|
||||
let list = p.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default();
|
||||
list.iter().map(|a| doc.resolve(a).as_dict().cloned().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn count_fonts(doc: &Document) -> usize {
|
||||
doc.object_numbers().iter().filter(|n| doc.get(ObjRef::new(**n, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Font"))).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_concatenates_with_a_bookmark_per_file() {
|
||||
let out = full_roundtrip(&combine(&[("Report A", &doc_a()), ("Report B", &doc_b())]).unwrap());
|
||||
assert_eq!(labels(&out), ["A1", "A2", "A3", "B1", "B2"]);
|
||||
// Shared resources are copied once per source: one Helvetica, one Times.
|
||||
assert_eq!(count_fonts(&out), 2);
|
||||
// Bookmarks: one per source file, pointing at its first page.
|
||||
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let outlines = out.get(cat.reference(b"Outlines").unwrap()).as_dict().cloned().unwrap();
|
||||
let first = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(first.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).as_deref(), Some("Report A"));
|
||||
let second = out.get(first.reference(b"Next").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(second.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).as_deref(), Some("Report B"));
|
||||
let dest_page = second.get(b"Dest").and_then(|d| d.as_array()).and_then(|a| a[0].as_ref()).unwrap();
|
||||
assert_eq!(dest_page, pages(&out).unwrap()[3].obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_attributes_travel_with_copied_pages() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_b(), &[1]).unwrap());
|
||||
let p = page_dict(&out, 0);
|
||||
assert_eq!(p.int(b"Rotate"), Some(90));
|
||||
assert!(p.contains(b"MediaBox") && p.contains(b"Resources"));
|
||||
let hay = hayro_syntax::Pdf::new(write_full(&out, &SaveOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(hay.pages().first().unwrap().render_dimensions(), (250.0, 500.0), "rotated 90°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_rewired_to_copied_pages_or_dropped() {
|
||||
let a = doc_a();
|
||||
// Extract A3, A1 (reordered): A1's GoTo A3 must now point at the copy of A3 (index 0).
|
||||
let out = full_roundtrip(&extract_pages(&a, &[2, 0]).unwrap());
|
||||
assert_eq!(labels(&out), ["A3", "A1"]);
|
||||
let link = &annots(&out, 1)[0];
|
||||
let action = out.resolve(link.get(b"A").unwrap());
|
||||
let target = action.as_dict().unwrap().get(b"D").and_then(|d| d.as_array()).and_then(|a| a[0].as_ref()).unwrap();
|
||||
assert_eq!(target, pages(&out).unwrap()[0].obj);
|
||||
assert_eq!(link.reference(b"P"), Some(pages(&out).unwrap()[1].obj), "/P points at the new page");
|
||||
// Extract only A2: its link to A1 would dangle, so the destination is dropped.
|
||||
let out = full_roundtrip(&extract_pages(&a, &[1]).unwrap());
|
||||
let link = annots(&out, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Link")).unwrap();
|
||||
assert!(!link.contains(b"Dest") && !link.contains(b"A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_fields_come_along_and_are_registered() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_a(), &[1]).unwrap());
|
||||
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let form = out.resolve(cat.get(b"AcroForm").unwrap());
|
||||
let fields = out.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()).as_array().cloned().unwrap();
|
||||
assert_eq!(fields.len(), 1);
|
||||
let field = out.resolve(&fields[0]).as_dict().cloned().unwrap();
|
||||
assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada"));
|
||||
assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_cycles_copy_once_and_structure_links_are_removed() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_a(), &[2]).unwrap());
|
||||
let p = page_dict(&out, 0);
|
||||
assert!(!p.contains(b"StructParents"));
|
||||
let note = &annots(&out, 0)[0];
|
||||
assert!(!note.contains(b"StructParent"));
|
||||
let popup = out.get(note.reference(b"Popup").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(
|
||||
out.get(popup.reference(b"Parent").unwrap()).as_dict().unwrap().get(b"Contents").and_then(|c| c.as_string()).map(|s| s.to_text()).as_deref(),
|
||||
Some("note")
|
||||
);
|
||||
// Nothing unrelated was dragged in: no pages A1/A2, no other content streams.
|
||||
assert_eq!(labels(&out), ["A3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_pages_from_another_file_is_an_incremental_edit() {
|
||||
let mut a = doc_a();
|
||||
import_pages(&mut a, &doc_b(), &[1, 0], 1).unwrap();
|
||||
let a = save_and_reopen(&a); // checks the prefix invariant and hayro agreement
|
||||
assert_eq!(labels(&a), ["A1", "B2", "B1", "A2", "A3"]);
|
||||
assert_eq!(info(&a, "Title").as_deref(), Some("Doc A"));
|
||||
assert_eq!(import_pages(&mut a.clone(), &doc_b(), &[5], 0), Err(OrganizeError::NoSuchPage(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_ranges_cover_every_page_once() {
|
||||
assert_eq!(split_ranges(5, &SplitBy::PageCount(2)), [0..2, 2..4, 4..5]);
|
||||
assert_eq!(split_ranges(5, &SplitBy::PageCount(0)), [0..1, 1..2, 2..3, 3..4, 4..5]);
|
||||
assert_eq!(split_ranges(5, &SplitBy::Before(vec![3, 1, 1, 9, 0])), [0..1, 1..3, 3..5]);
|
||||
assert_eq!(split_ranges(3, &SplitBy::PageCount(10)), vec![0..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_produces_standalone_documents_with_metadata() {
|
||||
let parts = split(&doc_a(), &SplitBy::PageCount(1)).unwrap();
|
||||
assert_eq!(parts.len(), 3);
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let part = full_roundtrip(part);
|
||||
assert_eq!(labels(&part), [format!("A{}", i + 1)]);
|
||||
assert_eq!(info(&part, "Author").as_deref(), Some("Alice"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_pages_become_independent_copies() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap());
|
||||
assert_eq!(labels(&out), ["B1", "B1"]);
|
||||
let ps = pages(&out).unwrap();
|
||||
assert_ne!(ps[0].obj, ps[1].obj, "a page object may appear only once in the tree");
|
||||
}
|
||||
|
||||
/// Document C: two pages. Page 1 has a link to the named destination `chap2` (page 2); page 2's
|
||||
/// content uses a layer that is off by default. It has a bookmark tree and one attachment.
|
||||
fn doc_c() -> Document {
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 9 0 R /EmbeddedFiles 13 0 R >> /OCProperties << /OCGs [8 0 R] /D << /OFF [8 0 R] >> >> /Outlines 10 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> /Annots [<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest (chap2) >>] >>".into(), // 3
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> /Properties << /L1 8 0 R >> >> >>".into(), // 4
|
||||
body("C1"), // 5
|
||||
body("C2"), // 6
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), // 7
|
||||
"<< /Type /OCG /Name (Draft marks) >>".into(), // 8
|
||||
"<< /Names [(chap2) [4 0 R /Fit]] >>".into(), // 9
|
||||
"<< /Type /Outlines /First 11 0 R /Last 11 0 R /Count 1 >>".into(), // 10
|
||||
"<< /Title (Chapter one) /Parent 10 0 R /Dest [3 0 R /Fit] /First 12 0 R /Last 12 0 R /Count 1 >>".into(), // 11
|
||||
"<< /Title (Section) /Parent 11 0 R /A << /S /GoTo /D (chap2) >> >>".into(), // 12
|
||||
"<< /Names [(notes.txt) 14 0 R] >>".into(), // 13
|
||||
"<< /Type /Filespec /F (notes.txt) /EF << /F 15 0 R >> >>".into(), // 14
|
||||
"<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(), // 15
|
||||
];
|
||||
open(build(&b, "/Root 1 0 R"))
|
||||
}
|
||||
|
||||
fn catalog(doc: &Document) -> Dict {
|
||||
doc.get(doc.root().unwrap()).as_dict().cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_destinations_are_resolved_to_the_copies() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_c(), &[0, 1]).unwrap());
|
||||
let link = &annots(&out, 0)[0];
|
||||
let dest = link.get(b"Dest").and_then(|d| d.as_array()).expect("explicit destination");
|
||||
assert_eq!(dest[0].as_ref(), Some(pages(&out).unwrap()[1].obj));
|
||||
// Without the target page, the link loses its destination instead of dangling.
|
||||
let alone = full_roundtrip(&extract_pages(&doc_c(), &[0]).unwrap());
|
||||
assert!(!annots(&alone, 0)[0].contains(b"Dest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_are_registered_with_their_default_state() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_c(), &[1]).unwrap());
|
||||
let props = out.resolve(catalog(&out).get(b"OCProperties").unwrap()).as_dict().cloned().unwrap();
|
||||
let ocgs = props.get(b"OCGs").and_then(|o| o.as_array()).unwrap().clone();
|
||||
assert_eq!(ocgs.len(), 1);
|
||||
let name = out.resolve(&ocgs[0]).as_dict().unwrap().get(b"Name").and_then(|n| n.as_string()).map(|s| s.to_text());
|
||||
assert_eq!(name.as_deref(), Some("Draft marks"));
|
||||
let d = props.get(b"D").and_then(|d| d.as_dict()).unwrap();
|
||||
assert_eq!(d.get(b"OFF").and_then(|o| o.as_array()).unwrap(), &ocgs, "stays off by default");
|
||||
// Page 1 uses no layer, so extracting it alone adds no OCProperties.
|
||||
let out = extract_pages(&doc_c(), &[0]).unwrap();
|
||||
assert!(!catalog(&out).contains(b"OCProperties"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_nests_source_bookmarks_and_keeps_attachments() {
|
||||
let out = full_roundtrip(&combine(&[("C", &doc_c()), ("C again", &doc_c())]).unwrap());
|
||||
assert_eq!(labels(&out), ["C1", "C2", "C1", "C2"]);
|
||||
let outlines = out.get(catalog(&out).reference(b"Outlines").unwrap()).as_dict().cloned().unwrap();
|
||||
let file = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(file.int(b"Count").unwrap() < 0, "source bookmarks start collapsed");
|
||||
let chapter = out.get(file.reference(b"First").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(chapter.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).as_deref(), Some("Chapter one"));
|
||||
let section = out.get(chapter.reference(b"First").unwrap()).as_dict().cloned().unwrap();
|
||||
let dest = section.get(b"Dest").and_then(|d| d.as_array()).expect("GoTo (named) became an explicit destination");
|
||||
assert_eq!(dest[0].as_ref(), Some(pages(&out).unwrap()[1].obj));
|
||||
// The second copy's bookmarks point into the second copy.
|
||||
let file2 = out.get(file.reference(b"Next").unwrap()).as_dict().cloned().unwrap();
|
||||
let chapter2 = out.get(file2.reference(b"First").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(chapter2.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(pages(&out).unwrap()[2].obj));
|
||||
// Attachments from both sources, the duplicate name disambiguated.
|
||||
let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap();
|
||||
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
|
||||
let keys: Vec<String> = tree.get(b"Names").and_then(|n| n.as_array()).unwrap().chunks(2).map(|p| p[0].as_string().unwrap().to_text()).collect();
|
||||
assert_eq!(keys, ["notes.txt", "notes.txt (2)"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@ js-sys = "0.3"
|
|||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url"] }
|
||||
|
||||
[dev-dependencies]
|
||||
printcraft-cos = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
//! ```text
|
||||
//! cargo run -p printcraft-ui-egui --example shot -- out.png [file.pdf] [--size 1440x900] [--scale 2] [--page 3 --panel pages …]
|
||||
//! ```
|
||||
//! Any `--key value` pair other than size/scale is passed to `PrintCraftApp::set_option`
|
||||
//! `--width N` downscales the image to N pixels wide (README screenshots). Any other
|
||||
//! `--key value` pair is passed to `PrintCraftApp::set_option`
|
||||
//! (the same verbs as the desktop app's command-line flags and the future control channel).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -16,6 +17,7 @@ fn main() -> Result<(), String> {
|
|||
let out = args.next().ok_or("usage: shot <out.png> [file.pdf] [--option value …]")?;
|
||||
let (mut file, mut opts) = (None, Vec::new());
|
||||
let (mut size, mut scale) = (egui::vec2(1440.0, 900.0), 2.0f32);
|
||||
let mut width: Option<u32> = None;
|
||||
while let Some(a) = args.next() {
|
||||
match a.strip_prefix("--") {
|
||||
Some("size") => {
|
||||
|
|
@ -24,6 +26,7 @@ fn main() -> Result<(), String> {
|
|||
size = egui::vec2(w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?);
|
||||
}
|
||||
Some("scale") => scale = args.next().and_then(|v| v.parse().ok()).ok_or("bad --scale")?,
|
||||
Some("width") => width = Some(args.next().and_then(|v| v.parse().ok()).ok_or("bad --width")?),
|
||||
Some(k) => opts.push((k.to_string(), args.next().unwrap_or_default())),
|
||||
None => file = Some(a),
|
||||
}
|
||||
|
|
@ -57,7 +60,11 @@ fn main() -> Result<(), String> {
|
|||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
let image = harness.render()?;
|
||||
let mut image = harness.render()?;
|
||||
if let Some(w) = width.filter(|w| *w < image.width()) {
|
||||
let h = (image.height() as f64 * w as f64 / image.width() as f64).round() as u32;
|
||||
image = image::imageops::resize(&image, w, h, image::imageops::FilterType::Lanczos3);
|
||||
}
|
||||
image.save(&out).map_err(|e| e.to_string())?;
|
||||
eprintln!("shot: wrote {out} ({}×{})", image.width(), image.height());
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -122,6 +122,16 @@ pub struct DocView {
|
|||
select_anchor: Option<usize>,
|
||||
/// An edit requested by the view (organize toolbar, keys), applied by the app this frame.
|
||||
pub pending_edit: Option<Edit>,
|
||||
/// A non-edit action requested by the organize toolbar, handled by the app.
|
||||
pub pending_action: Option<ViewAction>,
|
||||
}
|
||||
|
||||
/// Organize-toolbar actions that need the app (file pickers, new tabs, dialogs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ViewAction {
|
||||
InsertFromFile,
|
||||
Extract,
|
||||
Split,
|
||||
}
|
||||
|
||||
impl DocView {
|
||||
|
|
@ -160,6 +170,7 @@ impl DocView {
|
|||
selected: BTreeSet::new(),
|
||||
select_anchor: None,
|
||||
pending_edit: None,
|
||||
pending_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -615,9 +626,13 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
|||
}
|
||||
let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize;
|
||||
let view = &mut app.views[index];
|
||||
notices(view, info, ui, &t);
|
||||
// Opened without the owner password and something is restricted.
|
||||
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
|
||||
if notices(view, info, secured, ui, &t) {
|
||||
app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security));
|
||||
}
|
||||
if view.organize {
|
||||
organize_grid(view, info, &doc.renderer, doc.editable(), ui, &t);
|
||||
organize_grid(view, info, &doc.renderer, doc.allows_assembly(), ui, &t);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1044,18 +1059,22 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t:
|
|||
}
|
||||
}
|
||||
|
||||
fn notices(view: &mut DocView, info: &DocInfo, ui: &mut egui::Ui, t: &Tokens) {
|
||||
/// The notice bar above the pages. Returns `true` when "Security settings" was clicked.
|
||||
fn notices(view: &mut DocView, info: &DocInfo, secured: bool, ui: &mut egui::Ui, t: &Tokens) -> bool {
|
||||
if view.notice_dismissed {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let msg = if !info.fields.is_empty() {
|
||||
let mut open_security = false;
|
||||
let msg = if secured {
|
||||
Some(("lock", "This document is secured. Some changes are restricted by its security settings.".to_string(), false))
|
||||
} else if !info.fields.is_empty() {
|
||||
Some(("text-cursor-input", format!("This document contains {} interactive form fields.", info.fields.len()), true))
|
||||
} else if !info.warnings.is_empty() {
|
||||
Some(("triangle-alert", info.warnings[0].clone(), false))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some((icon, text, fields)) = msg else { return };
|
||||
let Some((icon, text, fields)) = msg else { return false };
|
||||
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image(icon, 16.0, t.accent_text));
|
||||
|
|
@ -1070,9 +1089,13 @@ fn notices(view: &mut DocView, info: &DocInfo, ui: &mut egui::Ui, t: &Tokens) {
|
|||
view.highlight_fields = !view.highlight_fields;
|
||||
}
|
||||
}
|
||||
if secured && crate::widgets::pill_button(ui, "Security settings", false).clicked() {
|
||||
open_security = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
open_security
|
||||
}
|
||||
|
||||
/// The floating quick-action bar at the left edge of the document area.
|
||||
|
|
@ -1141,6 +1164,16 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut
|
|||
let (w, h) = ((c[2] - c[0]).abs().max(1.0) as f64, (c[3] - c[1]).abs().max(1.0) as f64);
|
||||
view.pending_edit = Some(Edit::InsertBlankPage { at: last + 1, width: w, height: h });
|
||||
}
|
||||
if icons::button(ui, "file-input", 30.0, false, "Insert pages from a file…").clicked() {
|
||||
view.pending_action = Some(ViewAction::InsertFromFile);
|
||||
}
|
||||
if icons::button(ui, "file-output", 30.0, false, "Extract pages to a new document").clicked() {
|
||||
view.pending_action = Some(ViewAction::Extract);
|
||||
}
|
||||
if icons::button(ui, "scissors", 30.0, false, "Split into files…").clicked() {
|
||||
view.pending_action = Some(ViewAction::Split);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if ui.add_enabled_ui(first > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, "Move earlier")).inner.clicked() {
|
||||
view.pending_edit = Some(Edit::MovePages { pages: targets.clone(), to: first - 1 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,51 +153,9 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
let resp = widgets::ghost_button(ui, "panel-left", "Menu");
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(230.0);
|
||||
ui.menu_button("File", |ui| {
|
||||
if widgets::menu_item(ui, "Open…", "⌘O").clicked() {
|
||||
app.open_dialog();
|
||||
}
|
||||
let has = app.active.is_some();
|
||||
if ui.add_enabled(has, egui::Button::new("Close file").shortcut_text("⌘W")).clicked()
|
||||
&& let Some(i) = app.active
|
||||
{
|
||||
app.request_close_tab(i);
|
||||
}
|
||||
let dirty = app.active_ids().and_then(|(_, id)| app.session.get(id)).is_some_and(|d| d.dirty);
|
||||
if ui.add_enabled(has && dirty, egui::Button::new("Save").shortcut_text("⌘S")).clicked() {
|
||||
app.save_active(crate::SaveTarget::InPlace);
|
||||
}
|
||||
if ui.add_enabled(has, egui::Button::new("Save as…").shortcut_text("⇧⌘S")).clicked() {
|
||||
app.save_active(crate::SaveTarget::As);
|
||||
}
|
||||
ui.separator();
|
||||
if ui.add_enabled(has, egui::Button::new("Find…").shortcut_text("⌘F")).clicked()
|
||||
&& let Some(i) = app.active
|
||||
{
|
||||
app.views[i].open_find();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.add_enabled(has, egui::Button::new("Document properties…").shortcut_text("⌘D")).clicked() {
|
||||
app.dialog = Some(Dialog::Properties(PropsTab::Description));
|
||||
}
|
||||
});
|
||||
ui.menu_button("Edit", |ui| {
|
||||
let doc = app.active_ids().and_then(|(_, id)| app.session.get(id));
|
||||
let undo = doc.and_then(|d| d.can_undo()).map(str::to_owned);
|
||||
let redo = doc.and_then(|d| d.can_redo()).map(str::to_owned);
|
||||
let label = |verb: &str, what: &Option<String>| what.as_ref().map(|w| format!("{verb} {w}")).unwrap_or_else(|| verb.to_string());
|
||||
if ui.add_enabled(undo.is_some(), egui::Button::new(label("Undo", &undo)).shortcut_text("⌘Z")).clicked() {
|
||||
app.undo();
|
||||
}
|
||||
if ui.add_enabled(redo.is_some(), egui::Button::new(label("Redo", &redo)).shortcut_text("⇧⌘Z")).clicked() {
|
||||
app.redo();
|
||||
}
|
||||
ui.separator();
|
||||
let has = app.active.is_some();
|
||||
if ui.add_enabled(has, egui::Button::new("Organize pages")).clicked() {
|
||||
app.run_command("page.organize");
|
||||
}
|
||||
});
|
||||
ui.menu_button("File", |ui| crate::commands::registry_menu(app, ui, "File"));
|
||||
ui.menu_button("Edit", |ui| crate::commands::registry_menu(app, ui, "Edit"));
|
||||
ui.menu_button("Pages", |ui| crate::commands::registry_menu(app, ui, "Pages"));
|
||||
ui.menu_button("View", |ui| {
|
||||
if let Some(i) = app.active {
|
||||
let v = &mut app.views[i];
|
||||
|
|
@ -235,13 +193,7 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
ui.separator();
|
||||
}
|
||||
if app.active.is_some() && widgets::menu_item(ui, "Full screen mode", "⌘L").clicked() {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.set_full_screen(&ctx, true);
|
||||
}
|
||||
if widgets::menu_item(ui, "Read mode", "⌃⌘H").clicked() {
|
||||
app.mode = if app.mode == Mode::Read { Mode::AllTools } else { Mode::Read };
|
||||
}
|
||||
crate::commands::registry_menu(app, ui, "View");
|
||||
ui.menu_button("Display theme", |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if ui.radio(app.theme == ThemeKind::Light, "Light gray").clicked() {
|
||||
|
|
@ -266,14 +218,7 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
});
|
||||
});
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Keyboard shortcuts").clicked() {
|
||||
app.dialog = Some(Dialog::Shortcuts);
|
||||
}
|
||||
if ui.button("About PrintCraft").clicked() {
|
||||
app.dialog = Some(Dialog::About);
|
||||
}
|
||||
});
|
||||
ui.menu_button("Help", |ui| crate::commands::registry_menu(app, ui, "Help"));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
//! What each registered command does in this frontend (`printcraft_engine::commands` says
|
||||
//! what it is called, where it appears, which key runs it and when it is enabled).
|
||||
//!
|
||||
//! Menus, keyboard shortcuts, the palette, the tool panels and automation (`set_option`, and
|
||||
//! the control channel later) all call `PrintCraftApp::execute`.
|
||||
|
||||
use printcraft_engine::Edit;
|
||||
use printcraft_engine::commands::{self, COMMANDS, CommandSpec};
|
||||
|
||||
use crate::{Dialog, Mode, PrintCraftApp, PropsTab, RightPanel, SaveTarget, theme::ThemeKind, widgets};
|
||||
|
||||
impl PrintCraftApp {
|
||||
pub(crate) fn command_enabled(&self, spec: &CommandSpec) -> bool {
|
||||
commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id))
|
||||
}
|
||||
|
||||
/// Run a registered command by id. Returns `false` when the id is unknown or the command
|
||||
/// is disabled right now (the user is told why).
|
||||
pub fn execute(&mut self, id: &str) -> bool {
|
||||
let Some(spec) = commands::command(id) else { return false };
|
||||
if !self.command_enabled(spec) {
|
||||
let why = match spec.needs {
|
||||
commands::Needs::Undo => "Nothing to undo".to_string(),
|
||||
commands::Needs::Redo => "Nothing to redo".to_string(),
|
||||
commands::Needs::Assembly | commands::Needs::Modification if self.active.is_some() => {
|
||||
"The document's security settings don't allow this change".to_string()
|
||||
}
|
||||
_ => "Open a document first".to_string(),
|
||||
};
|
||||
self.notify(why);
|
||||
return false;
|
||||
}
|
||||
let active = self.active;
|
||||
let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default();
|
||||
match id {
|
||||
"file.open" => self.open_dialog(),
|
||||
"page.combine" => self.combine_dialog(),
|
||||
"file.save" => {
|
||||
self.save_active(SaveTarget::InPlace);
|
||||
}
|
||||
"file.save_as" => {
|
||||
self.save_active(SaveTarget::As);
|
||||
}
|
||||
"file.close" => {
|
||||
if let Some(i) = active {
|
||||
self.request_close_tab(i);
|
||||
}
|
||||
}
|
||||
"file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)),
|
||||
"protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)),
|
||||
"edit.undo" => self.undo(),
|
||||
"edit.redo" => self.redo(),
|
||||
"edit.find" => {
|
||||
if let Some(i) = active {
|
||||
self.views[i].open_find();
|
||||
}
|
||||
}
|
||||
"view.palette" => self.palette_open = !self.palette_open,
|
||||
"view.full_screen" => {
|
||||
let on = !self.full_screen;
|
||||
match self.ctx.clone() {
|
||||
Some(ctx) => self.set_full_screen(&ctx, on),
|
||||
None => self.full_screen = on,
|
||||
}
|
||||
}
|
||||
"view.read_mode" => self.mode = if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read },
|
||||
"view.theme" => {
|
||||
let next = if self.theme == ThemeKind::Light { ThemeKind::Dark } else { ThemeKind::Light };
|
||||
match self.ctx.clone() {
|
||||
Some(ctx) => self.set_theme(&ctx, next),
|
||||
None => self.theme = next,
|
||||
}
|
||||
}
|
||||
"comment.list" => self.right = Some(RightPanel::Comments),
|
||||
"form.fields" => self.right = Some(RightPanel::Fields),
|
||||
"page.organize" => {
|
||||
if let Some(i) = active {
|
||||
self.views[i].organize = !self.views[i].organize;
|
||||
}
|
||||
}
|
||||
"page.rotate" => {
|
||||
self.apply_edit(Edit::RotatePages { pages: targets, degrees: 90 });
|
||||
}
|
||||
"page.rotate_ccw" => {
|
||||
self.apply_edit(Edit::RotatePages { pages: targets, degrees: -90 });
|
||||
}
|
||||
"page.delete" => {
|
||||
self.apply_edit(Edit::DeletePages { pages: targets });
|
||||
}
|
||||
"page.insert_blank" => {
|
||||
if let (Some(i), Some(&last)) = (active, targets.last())
|
||||
&& let Some(p) = self.session.get(self.views[i].id).and_then(|d| d.info.pages.get(last))
|
||||
{
|
||||
let (w, h) = ((p.crop[2] - p.crop[0]).abs().max(1.0) as f64, (p.crop[3] - p.crop[1]).abs().max(1.0) as f64);
|
||||
self.apply_edit(Edit::InsertBlankPage { at: last + 1, width: w, height: h });
|
||||
}
|
||||
}
|
||||
"page.insert" => self.insert_from_file_dialog(),
|
||||
"page.extract" => self.extract_selection(),
|
||||
"page.split" => self.dialog = Some(Dialog::Split),
|
||||
"help.shortcuts" => self.dialog = Some(Dialog::Shortcuts),
|
||||
"help.about" => self.dialog = Some(Dialog::About),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Run the registered keyboard shortcuts (more specific combinations first).
|
||||
pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect();
|
||||
specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0)));
|
||||
for spec in specs {
|
||||
let s = spec.shortcut.expect("filtered");
|
||||
if typing && !spec.in_text {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = Key::from_name(s.key) else { continue };
|
||||
let mut m = Modifiers::NONE;
|
||||
if s.command {
|
||||
m |= Modifiers::COMMAND;
|
||||
}
|
||||
if s.shift {
|
||||
m |= Modifiers::SHIFT;
|
||||
}
|
||||
if s.mac_ctrl {
|
||||
m |= Modifiers::CTRL;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) {
|
||||
self.execute(spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
|
||||
pub(crate) fn registry_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui, menu: &str) {
|
||||
let mac = cfg!(target_os = "macos") || cfg!(target_arch = "wasm32");
|
||||
for spec in commands::menu(menu) {
|
||||
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
|
||||
let shortcut = spec.shortcut.map(|s| s.label(mac)).unwrap_or_default();
|
||||
let enabled = app.command_enabled(spec);
|
||||
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut));
|
||||
if resp.clicked() {
|
||||
app.execute(spec.id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let _ = widgets::menu_item;
|
||||
}
|
||||
|
|
@ -24,11 +24,14 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default())));
|
||||
}
|
||||
let mut apply = false;
|
||||
let mut split_now: Option<printcraft_engine::SplitBy> = None;
|
||||
let mut split_ready: Option<printcraft_engine::SplitBy> = None;
|
||||
let mut recover: Option<bool> = None;
|
||||
let t = Tokens::get(ctx);
|
||||
let mut close = false;
|
||||
let mut next = dialog;
|
||||
let modal = egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
|
||||
ui.set_width(560.0);
|
||||
ui.set_width(if matches!(dialog, Dialog::Properties(_)) { 640.0 } else { 520.0 });
|
||||
match dialog {
|
||||
Dialog::Properties(tab) => {
|
||||
ui.label(egui::RichText::new("Document Properties").font(theme::semibold(18.0)));
|
||||
|
|
@ -59,10 +62,17 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
PropsTab::Description => {
|
||||
row(ui, "File", doc.name.clone());
|
||||
match app.props_draft.as_mut() {
|
||||
Some((_, draft)) if doc.editable() => {
|
||||
Some((_, draft)) if doc.allows_modification() => {
|
||||
for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
|
||||
let l = ui.label(egui::RichText::new(*k).color(t.text_muted));
|
||||
ui.add(egui::TextEdit::singleline(v).desired_width(f32::INFINITY).id_salt(("info", *k))).labelled_by(l.id);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(v)
|
||||
.desired_width(420.0)
|
||||
.background_color(t.field)
|
||||
.margin(egui::Margin::symmetric(6, 4))
|
||||
.id_salt(("info", *k)),
|
||||
)
|
||||
.labelled_by(l.id);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
|
@ -76,20 +86,40 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
row(ui, "Application", i.creator.clone().unwrap_or_default());
|
||||
row(ui, "PDF producer", i.producer.clone().unwrap_or_default());
|
||||
}
|
||||
PropsTab::Security => {
|
||||
row(ui, "Security method", if i.encrypted { "Password security".into() } else { "No security".into() });
|
||||
for k in [
|
||||
"Printing",
|
||||
"Changing the document",
|
||||
"Document assembly",
|
||||
"Content copying",
|
||||
"Commenting",
|
||||
"Filling of form fields",
|
||||
"Signing",
|
||||
] {
|
||||
row(ui, k, if i.encrypted { "See permissions (M8)".into() } else { "Allowed".into() });
|
||||
PropsTab::Security => match doc.security_summary() {
|
||||
None => {
|
||||
row(ui, "Security method", "No security".into());
|
||||
row(ui, "Restrictions", "None — everything is allowed".into());
|
||||
}
|
||||
}
|
||||
Some(sec) => {
|
||||
row(ui, "Security method", "Password security".into());
|
||||
row(ui, "Encryption", sec.method.clone());
|
||||
row(
|
||||
ui,
|
||||
"Opened with",
|
||||
if sec.owner { "Owner password (no restrictions apply)".into() } else { "User password".into() },
|
||||
);
|
||||
let p = sec.permissions;
|
||||
let yes = |b: bool| if b { "Allowed".to_string() } else { "Not allowed".to_string() };
|
||||
row(
|
||||
ui,
|
||||
"Printing",
|
||||
if !p.print() {
|
||||
"Not allowed".into()
|
||||
} else if p.print_high_quality() {
|
||||
"High resolution".into()
|
||||
} else {
|
||||
"Low resolution".into()
|
||||
},
|
||||
);
|
||||
row(ui, "Changing the document", yes(p.modify()));
|
||||
row(ui, "Document assembly", yes(p.assemble()));
|
||||
row(ui, "Content copying", yes(p.copy()));
|
||||
row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility()));
|
||||
row(ui, "Commenting", yes(p.annotate()));
|
||||
row(ui, "Filling of form fields", yes(p.fill_forms()));
|
||||
}
|
||||
},
|
||||
PropsTab::Fonts => {
|
||||
if i.fonts.is_empty() {
|
||||
row(ui, "Fonts", "No fonts are referenced by the pages.".into());
|
||||
|
|
@ -126,35 +156,100 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
})
|
||||
});
|
||||
}
|
||||
Dialog::Split => {
|
||||
ui.label(egui::RichText::new("Split document").font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
let Some((vi, id)) = app.active_ids() else { return };
|
||||
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
|
||||
let selected: Vec<usize> = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect();
|
||||
let draft = &mut app.split_draft;
|
||||
if selected.is_empty() {
|
||||
draft.at_selection = false;
|
||||
}
|
||||
ui.radio_value(&mut draft.at_selection, false, "By number of pages");
|
||||
ui.add_enabled_ui(!draft.at_selection, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.label("Pages per file");
|
||||
ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1)));
|
||||
});
|
||||
});
|
||||
ui.add_enabled_ui(!selected.is_empty(), |ui| {
|
||||
ui.radio_value(&mut draft.at_selection, true, "Before each selected page (select pages in Organize)")
|
||||
});
|
||||
let by = if draft.at_selection {
|
||||
printcraft_engine::SplitBy::Before(selected)
|
||||
} else {
|
||||
printcraft_engine::SplitBy::PageCount(draft.every)
|
||||
};
|
||||
let files = printcraft_engine::split_ranges(n, &by).len();
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Creates {files} file{} from {n} pages.", if files == 1 { "" } else { "s" })).color(t.text_muted),
|
||||
);
|
||||
if files > 1 {
|
||||
split_ready = Some(by);
|
||||
}
|
||||
}
|
||||
Dialog::Recovery => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(crate::icons::image("clock-3", 22.0, t.accent));
|
||||
ui.label(egui::RichText::new("Recover unsaved documents?").font(theme::semibold(18.0)));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label("PrintCraft didn't shut down normally. These documents had changes that were autosaved:");
|
||||
ui.add_space(8.0);
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| {
|
||||
for m in &app.recoverable {
|
||||
ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0)));
|
||||
let mins = now.saturating_sub(m.saved_at) / 60;
|
||||
let when = match mins {
|
||||
0 => "just now".to_string(),
|
||||
1..=59 => format!("{mins} min ago"),
|
||||
_ => format!("{} h ago", mins / 60),
|
||||
};
|
||||
let lock = if m.encrypted { " · password-protected" } else { "" };
|
||||
ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
Dialog::Shortcuts => {
|
||||
ui.label(egui::RichText::new("Keyboard shortcuts").font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| {
|
||||
for (k, v) in [
|
||||
("⌘O", "Open"),
|
||||
("⌘W", "Close file"),
|
||||
("⌘S / ⇧⌘S", "Save / Save as"),
|
||||
("⌘Z / ⇧⌘Z", "Undo / Redo"),
|
||||
("Delete", "Delete selected pages (Organize)"),
|
||||
("⌘D", "Document properties"),
|
||||
("⌘K", "Find tools and commands"),
|
||||
("⌘F", "Find text in the document"),
|
||||
("⌘G / ⇧⌘G", "Next / previous match"),
|
||||
("⌘C", "Copy selected text"),
|
||||
("Double-click", "Select a word"),
|
||||
("Esc", "Clear selection / close find"),
|
||||
("⌘1", "Actual size"),
|
||||
("⌘0", "Zoom to page level"),
|
||||
("⌘2", "Fit to width"),
|
||||
("⌘+ / ⌘−", "Zoom in / out (also pinch or ⌘-scroll)"),
|
||||
("Home / End", "First / last page"),
|
||||
("⌘← / ⌘→", "Previous / next page"),
|
||||
("⌃⌘H", "Read mode"),
|
||||
] {
|
||||
ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5)));
|
||||
ui.label(v);
|
||||
ui.end_row();
|
||||
}
|
||||
let mac = cfg!(target_os = "macos") || cfg!(target_arch = "wasm32");
|
||||
// Registered commands first (always in sync with the real bindings), then the
|
||||
// keys the document view handles itself.
|
||||
let mut rows: Vec<(String, String)> = printcraft_engine::commands::COMMANDS
|
||||
.iter()
|
||||
.filter_map(|c| c.shortcut.map(|k| (k.label(mac), c.label.trim_end_matches('…').to_string())))
|
||||
.collect();
|
||||
for (k, v) in [
|
||||
("⌘G / ⇧⌘G", "Next / previous match"),
|
||||
("⌘C", "Copy selected text"),
|
||||
("Double-click", "Select a word"),
|
||||
("Esc", "Clear selection / close find"),
|
||||
("⌘1", "Actual size"),
|
||||
("⌘0", "Zoom to page level"),
|
||||
("⌘2", "Fit to width"),
|
||||
("⌘+ / ⌘−", "Zoom in / out (also pinch or ⌘-scroll)"),
|
||||
("⇧⌘+ / ⇧⌘−", "Rotate view"),
|
||||
("Home / End", "First / last page"),
|
||||
("⌘← / ⌘→", "Previous / next page"),
|
||||
("Delete", "Delete selected pages (Organize)"),
|
||||
("⌘A", "Select all pages (Organize)"),
|
||||
] {
|
||||
rows.push((k.to_string(), v.to_string()));
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| {
|
||||
egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| {
|
||||
for (k, v) in &rows {
|
||||
ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5)));
|
||||
ui.label(v);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::About => {
|
||||
|
|
@ -172,7 +267,24 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
ui.add_space(12.0);
|
||||
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if changed {
|
||||
if dialog == Dialog::Recovery {
|
||||
if widgets::pill_button(ui, "Recover", true).clicked() {
|
||||
recover = Some(true);
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, "Discard", false).clicked() {
|
||||
recover = Some(false);
|
||||
close = true;
|
||||
}
|
||||
} else if dialog == Dialog::Split {
|
||||
if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, "Split", true)).inner.clicked() {
|
||||
split_now = split_ready.clone();
|
||||
close = true;
|
||||
}
|
||||
if widgets::pill_button(ui, "Cancel", false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
} else if changed {
|
||||
if widgets::pill_button(ui, "OK", true).clicked() {
|
||||
apply = true;
|
||||
close = true;
|
||||
|
|
@ -185,6 +297,17 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
});
|
||||
if let Some(yes) = recover {
|
||||
let keys: Vec<String> = app.recoverable.iter().map(|m| m.key.clone()).collect();
|
||||
if yes {
|
||||
app.recover(&keys);
|
||||
} else {
|
||||
app.discard_recovered(&keys);
|
||||
}
|
||||
}
|
||||
if let Some(by) = split_now {
|
||||
app.split_active(&by);
|
||||
}
|
||||
if apply && let Some(edits) = draft_changes(app) {
|
||||
app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits });
|
||||
}
|
||||
|
|
@ -274,10 +397,12 @@ fn password(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
ui.label(format!("“{}” is protected. Enter a password to open it.", prompt.name));
|
||||
ui.add_space(8.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut prompt.input).password(true).hint_text("Password").desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
// Enter submits. The field keeps focus (we request it every frame), so check the key
|
||||
// while it is focused as well as on the frame focus is lost.
|
||||
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
submit = true;
|
||||
}
|
||||
r.request_focus();
|
||||
if let Some(e) = &prompt.error {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(e).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B)));
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ impl PrintCraftApp {
|
|||
if let Some(edit) = self.views.get_mut(i).and_then(|v| v.pending_edit.take()) {
|
||||
self.apply_edit(edit);
|
||||
}
|
||||
match self.views.get_mut(i).and_then(|v| v.pending_action.take()) {
|
||||
Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(),
|
||||
Some(crate::canvas::ViewAction::Extract) => self.extract_selection(),
|
||||
Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the active document. Returns `true` if it was written.
|
||||
|
|
@ -116,6 +122,7 @@ impl PrintCraftApp {
|
|||
}
|
||||
match self.session.mark_saved(id, bytes, Some(dest.clone())) {
|
||||
Ok(()) => {
|
||||
self.forget_recovery(id);
|
||||
let info = &self.session.get(id).expect("exists").info;
|
||||
self.views[index].document_changed(info);
|
||||
self.notify(format!("Saved {}", short_name(&dest)));
|
||||
|
|
@ -210,16 +217,21 @@ impl PrintCraftApp {
|
|||
|
||||
/// Intercept window close while documents have unsaved changes.
|
||||
pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.allow_quit && self.first_dirty().is_some() {
|
||||
if !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
if !self.allow_quit && self.first_dirty().is_some() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
} else {
|
||||
// A clean quit: nothing is left to recover.
|
||||
self.shutdown_recovery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temporary file in the same directory and rename over the target, so a crash or
|
||||
/// full disk never leaves a half-written PDF where the original was.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let target = std::path::Path::new(path);
|
||||
|
|
@ -244,7 +256,7 @@ fn short_name(path: &str) -> String {
|
|||
|
||||
/// Offer bytes as a browser download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let err = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let array = js_sys::Uint8Array::from(bytes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Multi-document page operations: Combine Files, Insert Pages from File, Extract Pages, Split.
|
||||
//!
|
||||
//! Desktop builds pick files synchronously. Browsers pick them asynchronously: the chosen
|
||||
//! files land in `requests` with their purpose and are handled on the next frame.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use printcraft_engine::{Edit, SplitBy};
|
||||
|
||||
use crate::PrintCraftApp;
|
||||
|
||||
/// Why files were picked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FilePurpose {
|
||||
Combine,
|
||||
InsertPages,
|
||||
}
|
||||
|
||||
/// Files picked asynchronously (web), waiting to be used: (purpose, [(name, bytes)]).
|
||||
pub type Requests = Arc<std::sync::Mutex<Vec<(FilePurpose, Vec<(String, Vec<u8>)>)>>>;
|
||||
|
||||
/// Settings for the Split dialog.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SplitDraft {
|
||||
/// Pages per file.
|
||||
pub every: usize,
|
||||
/// Split before each selected page instead of by count.
|
||||
pub at_selection: bool,
|
||||
}
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Ask for files to combine (File ▸ Combine files…).
|
||||
pub fn combine_dialog(&mut self) {
|
||||
self.pick_files(FilePurpose::Combine, true);
|
||||
}
|
||||
|
||||
/// Ask for a PDF whose pages to insert after the selection (Organize ▸ Insert from file).
|
||||
pub fn insert_from_file_dialog(&mut self) {
|
||||
if self.active.is_none() {
|
||||
self.notify("Open a document first");
|
||||
return;
|
||||
}
|
||||
self.pick_files(FilePurpose::InsertPages, false);
|
||||
}
|
||||
|
||||
fn pick_files(&mut self, purpose: FilePurpose, multiple: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dialog = rfd::FileDialog::new().add_filter("PDF", &["pdf"]);
|
||||
let paths = if multiple { dialog.pick_files().unwrap_or_default() } else { dialog.pick_file().into_iter().collect() };
|
||||
let mut files = Vec::new();
|
||||
for p in paths {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file.pdf".into());
|
||||
match std::fs::read(&p) {
|
||||
Ok(b) => files.push((name, b)),
|
||||
Err(e) => {
|
||||
self.notify(format!("Couldn't read {name}: {e}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
self.use_files(purpose, files);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let requests = self.requests.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]);
|
||||
let handles = if multiple { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
|
||||
let mut files = Vec::new();
|
||||
for h in handles {
|
||||
files.push((h.file_name(), h.read().await));
|
||||
}
|
||||
if !files.is_empty()
|
||||
&& let Ok(mut q) = requests.lock()
|
||||
{
|
||||
q.push((purpose, files));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle files picked asynchronously.
|
||||
pub(crate) fn process_file_requests(&mut self) {
|
||||
let pending: Vec<_> = self.requests.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
|
||||
for (purpose, files) in pending {
|
||||
self.use_files(purpose, files);
|
||||
}
|
||||
}
|
||||
|
||||
/// Use picked files (also the entry point for tests and automation).
|
||||
pub fn use_files(&mut self, purpose: FilePurpose, files: Vec<(String, Vec<u8>)>) {
|
||||
match purpose {
|
||||
FilePurpose::Combine => self.combine_files(files),
|
||||
FilePurpose::InsertPages => {
|
||||
for (name, bytes) in files {
|
||||
self.insert_pages_from(&name, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine files, in order, into a new unsaved document tab.
|
||||
pub fn combine_files(&mut self, files: Vec<(String, Vec<u8>)>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let sources: Vec<(String, Arc<Vec<u8>>)> = files.into_iter().map(|(n, b)| (strip_pdf(&n).to_string(), Arc::new(b))).collect();
|
||||
let count = sources.len();
|
||||
match self.session.combine(&sources) {
|
||||
Ok(bytes) => self.open_created("Combined.pdf", bytes, &format!("Combined {count} files")),
|
||||
Err(e) => self.notify(format!("Couldn't combine files: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert all pages of a PDF after the organize selection (or the current page).
|
||||
pub fn insert_pages_from(&mut self, name: &str, bytes: Vec<u8>) {
|
||||
let Some(i) = self.active else { return };
|
||||
let at = self.views[i].target_pages().last().map(|p| p + 1).unwrap_or(0);
|
||||
self.apply_edit(Edit::InsertPagesFrom { name: name.to_string(), bytes: Arc::new(bytes), pages: None, at });
|
||||
}
|
||||
|
||||
/// Copy the selected pages (or the current page) into a new unsaved document tab.
|
||||
pub fn extract_selection(&mut self) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let pages = self.views[i].target_pages();
|
||||
let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default();
|
||||
match self.session.extract(id, &pages) {
|
||||
Ok(bytes) => self.open_created(&format!("{stem} (extract).pdf"), bytes, &format!("Extracted {} page(s)", pages.len())),
|
||||
Err(e) => self.notify(format!("Couldn't extract pages: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the active document and write the parts: into a chosen folder (desktop) or as
|
||||
/// downloads (web). Returns the number of files written.
|
||||
pub fn split_active(&mut self, by: &SplitBy) -> usize {
|
||||
let Some((_, id)) = self.active_ids() else { return 0 };
|
||||
let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_else(|| "document".into());
|
||||
let parts = match self.session.split(id, by) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
self.notify(format!("Couldn't split: {e}"));
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let named: Vec<(String, Arc<Vec<u8>>)> = parts
|
||||
.into_iter()
|
||||
.map(|(a, b, bytes)| (if a == b { format!("{stem} (page {a}).pdf") } else { format!("{stem} (pages {a}-{b}).pdf") }, bytes))
|
||||
.collect();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = match &self.export_dir_override {
|
||||
Some(d) => Some(std::path::PathBuf::from(d)),
|
||||
None => rfd::FileDialog::new().set_title("Choose a folder for the split files").pick_folder(),
|
||||
};
|
||||
let Some(dir) = dir else { return 0 };
|
||||
for (name, bytes) in &named {
|
||||
if let Err(e) = crate::editing::write_atomically(&dir.join(name).to_string_lossy(), bytes) {
|
||||
self.notify(format!("Couldn't write {name}: {e}"));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
self.notify(format!("Split into {} files in {}", named.len(), dir.display()));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (name, bytes) in &named {
|
||||
if let Err(e) = crate::editing::download(name, bytes) {
|
||||
self.notify(format!("Couldn't download {name}: {e}"));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
named.len()
|
||||
}
|
||||
|
||||
fn open_created(&mut self, name: &str, bytes: Arc<Vec<u8>>, message: &str) {
|
||||
match self.session.open_new(name, bytes) {
|
||||
Ok(id) => {
|
||||
let info = &self.session.get(id).expect("just opened").info;
|
||||
self.views.push(crate::DocView::new(id, info));
|
||||
self.active = Some(self.views.len() - 1);
|
||||
self.notify(message);
|
||||
}
|
||||
Err(e) => self.notify(format!("Couldn't open the result: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_pdf(name: &str) -> &str {
|
||||
name.strip_suffix(".pdf").or_else(|| name.strip_suffix(".PDF")).unwrap_or(name)
|
||||
}
|
||||
|
|
@ -7,13 +7,16 @@
|
|||
|
||||
mod canvas;
|
||||
mod chrome;
|
||||
mod commands;
|
||||
mod dialogs;
|
||||
mod editing;
|
||||
mod files;
|
||||
mod home;
|
||||
mod icon_data;
|
||||
pub mod icons;
|
||||
mod palette;
|
||||
mod panels;
|
||||
mod recovery;
|
||||
pub mod theme;
|
||||
mod widgets;
|
||||
|
||||
|
|
@ -21,6 +24,8 @@ use printcraft_engine::{DocId, Session};
|
|||
|
||||
pub use canvas::DocView;
|
||||
pub use editing::{CloseRequest, SaveTarget};
|
||||
pub use files::{FilePurpose, SplitDraft};
|
||||
pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore};
|
||||
use theme::ThemeKind;
|
||||
|
||||
/// Top-level workspace modes (Acrobat's mode bar).
|
||||
|
|
@ -63,6 +68,9 @@ pub enum Dialog {
|
|||
Properties(PropsTab),
|
||||
About,
|
||||
Shortcuts,
|
||||
Split,
|
||||
/// Documents from a session that ended unexpectedly.
|
||||
Recovery,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -121,7 +129,22 @@ pub struct PrintCraftApp {
|
|||
pub save_override: Option<String>,
|
||||
/// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords).
|
||||
pub props_draft: Option<(DocId, [String; 4])>,
|
||||
/// Files picked asynchronously for combine / insert (web).
|
||||
pub requests: files::Requests,
|
||||
/// Write exported files (split) here instead of asking (tests and automation).
|
||||
pub export_dir_override: Option<String>,
|
||||
/// Split dialog settings.
|
||||
pub split_draft: SplitDraft,
|
||||
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
|
||||
pub recovery: Option<RecoveryStore>,
|
||||
/// Entries left by a previous session, offered in the Recovery dialog.
|
||||
pub recoverable: Vec<RecoveryMeta>,
|
||||
recovery_keys: std::collections::HashMap<DocId, String>,
|
||||
last_autosave: f64,
|
||||
pending_recovered: Option<RecoveryMeta>,
|
||||
allow_quit: bool,
|
||||
/// The egui context, for commands that change window or theme state.
|
||||
ctx: Option<egui::Context>,
|
||||
pending_theme: Option<ThemeKind>,
|
||||
styled: bool,
|
||||
fonts_ready: bool,
|
||||
|
|
@ -158,7 +181,16 @@ impl PrintCraftApp {
|
|||
close_request: None,
|
||||
save_override: None,
|
||||
props_draft: None,
|
||||
requests: Default::default(),
|
||||
export_dir_override: None,
|
||||
split_draft: SplitDraft { every: 1, at_selection: false },
|
||||
recovery: None,
|
||||
recoverable: Vec::new(),
|
||||
recovery_keys: Default::default(),
|
||||
last_autosave: 0.0,
|
||||
pending_recovered: None,
|
||||
allow_quit: false,
|
||||
ctx: None,
|
||||
pending_theme: None,
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
|
|
@ -275,8 +307,15 @@ impl PrintCraftApp {
|
|||
pub fn submit_password(&mut self, password: Option<String>) {
|
||||
let Some(p) = self.password_prompt.take() else { return };
|
||||
let Some(pw) = password else { return };
|
||||
if let Err(e) = self.try_open(&p.name, p.path, p.bytes, Some(&pw)) {
|
||||
self.notify(format!("Couldn't open {}: {e}", p.name));
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw)) {
|
||||
Err(e) => self.notify(format!("Couldn't open {}: {e}", p.name)),
|
||||
// A recovered encrypted document is open once the prompt is gone.
|
||||
Ok(()) if self.password_prompt.is_none() => {
|
||||
if let Some(meta) = self.pending_recovered.clone() {
|
||||
self.finish_recovery(&meta);
|
||||
}
|
||||
}
|
||||
Ok(()) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,6 +357,7 @@ impl PrintCraftApp {
|
|||
return;
|
||||
}
|
||||
let id = self.views.remove(index).id;
|
||||
self.forget_recovery(id);
|
||||
self.session.close(id);
|
||||
self.active = match self.active {
|
||||
_ if self.views.is_empty() => None,
|
||||
|
|
@ -341,31 +381,22 @@ impl PrintCraftApp {
|
|||
|
||||
/// Run a catalogue command. Commands that aren't implemented yet say which milestone ships them.
|
||||
pub fn run_command(&mut self, command: &str) {
|
||||
match command {
|
||||
"page.organize" => {
|
||||
if let Some(i) = self.active {
|
||||
self.views[i].organize = !self.views[i].organize;
|
||||
} else {
|
||||
self.notify("Open a document first");
|
||||
}
|
||||
}
|
||||
"comment.list" => self.right = Some(RightPanel::Comments),
|
||||
"form.fields" => self.right = Some(RightPanel::Fields),
|
||||
"protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)),
|
||||
other => {
|
||||
let when = printcraft_engine::catalog::TOOL_GROUPS
|
||||
.iter()
|
||||
.flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter()))
|
||||
.find(|i| i.command == other)
|
||||
.map(|i| match i.availability {
|
||||
printcraft_engine::catalog::Availability::Planned(m) => format!("ships in milestone {m}"),
|
||||
printcraft_engine::catalog::Availability::Provider => "needs an AI provider (off by default)".to_string(),
|
||||
printcraft_engine::catalog::Availability::Ready => "is available".to_string(),
|
||||
})
|
||||
.unwrap_or_else(|| "is not available yet".into());
|
||||
self.notify(format!("`{other}` {when}"));
|
||||
}
|
||||
if printcraft_engine::commands::command(command).is_some() {
|
||||
self.execute(command);
|
||||
return;
|
||||
}
|
||||
// Not implemented yet: say which milestone ships it.
|
||||
let when = printcraft_engine::catalog::TOOL_GROUPS
|
||||
.iter()
|
||||
.flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter()))
|
||||
.find(|i| i.command == command)
|
||||
.map(|i| match i.availability {
|
||||
printcraft_engine::catalog::Availability::Planned(m) => format!("ships in milestone {m}"),
|
||||
printcraft_engine::catalog::Availability::Provider => "needs an AI provider (off by default)".to_string(),
|
||||
printcraft_engine::catalog::Availability::Ready => "is available".to_string(),
|
||||
})
|
||||
.unwrap_or_else(|| "is not available yet".into());
|
||||
self.notify(format!("`{command}` {when}"));
|
||||
}
|
||||
|
||||
/// Serialize the user's persistent state (recent files, theme). Local only.
|
||||
|
|
@ -430,10 +461,16 @@ impl PrintCraftApp {
|
|||
("dialog", _) => {
|
||||
self.dialog = match value {
|
||||
"properties" => Some(Dialog::Properties(PropsTab::Description)),
|
||||
"security" => Some(Dialog::Properties(PropsTab::Security)),
|
||||
"fonts" => Some(Dialog::Properties(PropsTab::Fonts)),
|
||||
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
|
||||
"shortcuts" => Some(Dialog::Shortcuts),
|
||||
"split" => Some(Dialog::Split),
|
||||
"none" => None,
|
||||
_ => Some(Dialog::About),
|
||||
}
|
||||
}
|
||||
("tools", _) => self.all_tools_expanded = value != "collapsed",
|
||||
("palette", _) => {
|
||||
self.palette_open = true;
|
||||
self.palette_query = value.to_string();
|
||||
|
|
@ -476,7 +513,13 @@ impl PrintCraftApp {
|
|||
v.rerun_find();
|
||||
}
|
||||
("fields", Some(v)) => v.highlight_fields = value != "off",
|
||||
(k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate"].contains(&k) => {
|
||||
("select", Some(v)) => {
|
||||
// `--select 2,3,5` (1-based) selects pages in the organize grid.
|
||||
let pages: Result<Vec<usize>, _> = value.split(',').map(|p| p.trim().parse::<usize>().map(|n| n.saturating_sub(1))).collect();
|
||||
v.select_pages(&pages.map_err(|_| "select: comma-separated page numbers")?);
|
||||
}
|
||||
("notice", Some(v)) => v.notice_dismissed = value == "off",
|
||||
(k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate", "select", "notice"].contains(&k) => {
|
||||
return Err(format!("`{k}` needs an open document"));
|
||||
}
|
||||
(other, _) => return Err(format!("unknown option {other}")),
|
||||
|
|
@ -485,47 +528,11 @@ impl PrintCraftApp {
|
|||
}
|
||||
|
||||
fn shortcuts(&mut self, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k);
|
||||
let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s));
|
||||
if pressed(cmd(Key::O)) {
|
||||
self.open_dialog();
|
||||
}
|
||||
if pressed(cmd(Key::K)) {
|
||||
self.palette_open = !self.palette_open;
|
||||
}
|
||||
if pressed(cmd(Key::D)) && self.active.is_some() {
|
||||
self.dialog = Some(Dialog::Properties(PropsTab::Description));
|
||||
}
|
||||
if pressed(cmd(Key::W))
|
||||
&& let Some(i) = self.active
|
||||
{
|
||||
self.request_close_tab(i);
|
||||
}
|
||||
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::S)) && self.active.is_some() {
|
||||
self.save_active(SaveTarget::As);
|
||||
}
|
||||
if pressed(cmd(Key::S)) && self.active.is_some() {
|
||||
self.save_active(SaveTarget::InPlace);
|
||||
}
|
||||
// Text fields keep their own undo; otherwise ⌘Z/⇧⌘Z step the document history.
|
||||
if !ctx.egui_wants_keyboard_input() && self.active.is_some() {
|
||||
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)) {
|
||||
self.redo();
|
||||
} else if pressed(cmd(Key::Z)) {
|
||||
self.undo();
|
||||
}
|
||||
}
|
||||
if pressed(cmd(Key::L)) && self.active.is_some() {
|
||||
let on = !self.full_screen;
|
||||
self.set_full_screen(ctx, on);
|
||||
}
|
||||
use egui::Key;
|
||||
self.registry_shortcuts(ctx);
|
||||
if self.full_screen && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
self.set_full_screen(ctx, false);
|
||||
}
|
||||
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::CTRL, Key::H)) {
|
||||
self.mode = if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read };
|
||||
}
|
||||
if let Some(i) = self.active {
|
||||
canvas::shortcuts(&mut self.views[i], ctx);
|
||||
}
|
||||
|
|
@ -538,6 +545,7 @@ impl eframe::App for PrintCraftApp {
|
|||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.ctx = Some(ctx.clone());
|
||||
if !self.styled {
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
theme::install_fonts(ctx);
|
||||
|
|
@ -560,8 +568,11 @@ impl eframe::App for PrintCraftApp {
|
|||
}
|
||||
}
|
||||
self.guard_quit(ctx);
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.autosave_tick(now);
|
||||
self.shortcuts(ctx);
|
||||
self.process_pending_edits();
|
||||
self.process_file_requests();
|
||||
// Pull finished renders into textures for every open document.
|
||||
for view in &mut self.views {
|
||||
if let Some(doc) = self.session.get(view.id) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! ⌘K command palette over the tool catalogue (fuzzy-ish substring match on tool and item names).
|
||||
//! ⌘K command palette: every registered command (with its shortcut) and every tool in the
|
||||
//! catalogue, with a fuzzy-ish substring match.
|
||||
|
||||
use egui::{Align2, CornerRadius, Rect, Sense, Stroke, vec2};
|
||||
use printcraft_engine::catalog::{Availability, TOOL_GROUPS};
|
||||
|
|
@ -7,9 +8,10 @@ use crate::theme::{self, Tokens};
|
|||
use crate::{LeftPanel, PrintCraftApp, icons};
|
||||
|
||||
struct Hit {
|
||||
group: &'static str,
|
||||
/// The tool panel to open (tools and catalogue items); `None` for plain commands.
|
||||
group: Option<&'static str>,
|
||||
label: String,
|
||||
detail: &'static str,
|
||||
detail: String,
|
||||
icon: &'static str,
|
||||
command: Option<&'static str>,
|
||||
ready: bool,
|
||||
|
|
@ -39,14 +41,32 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
let q = app.palette_query.trim().to_lowercase();
|
||||
let mut hits: Vec<(usize, Hit)> = Vec::new();
|
||||
let mac = cfg!(target_os = "macos") || cfg!(target_arch = "wasm32");
|
||||
let active = app.active_ids().map(|(_, id)| id);
|
||||
for spec in printcraft_engine::commands::COMMANDS {
|
||||
let label = printcraft_engine::commands::current_label(spec, &app.session, active);
|
||||
if let Some(s) = score(&label, &q).or_else(|| score(spec.id, &q).map(|s| s + 50)) {
|
||||
hits.push((
|
||||
s,
|
||||
Hit {
|
||||
group: None,
|
||||
label,
|
||||
detail: spec.shortcut.map(|k| k.label(mac)).unwrap_or_else(|| spec.menu.unwrap_or("Command").to_string()),
|
||||
icon: spec.icon,
|
||||
command: Some(spec.id),
|
||||
ready: app.command_enabled(spec),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
for g in TOOL_GROUPS {
|
||||
if let Some(s) = score(g.label, &q) {
|
||||
hits.push((
|
||||
s,
|
||||
Hit {
|
||||
group: g.id,
|
||||
group: Some(g.id),
|
||||
label: g.label.to_string(),
|
||||
detail: "Tool",
|
||||
detail: "Tool".into(),
|
||||
icon: g.icon,
|
||||
command: None,
|
||||
ready: g.availability == Availability::Ready,
|
||||
|
|
@ -55,13 +75,16 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
for sec in g.sections {
|
||||
for i in sec.items {
|
||||
if printcraft_engine::commands::command(i.command).is_some() {
|
||||
continue; // listed above as a command
|
||||
}
|
||||
if let Some(s) = score(i.label, &q).or_else(|| score(i.command, &q).map(|s| s + 50)) {
|
||||
hits.push((
|
||||
s + 1,
|
||||
Hit {
|
||||
group: g.id,
|
||||
group: Some(g.id),
|
||||
label: i.label.to_string(),
|
||||
detail: g.label,
|
||||
detail: g.label.into(),
|
||||
icon: i.icon,
|
||||
command: Some(i.command),
|
||||
ready: i.availability == Availability::Ready,
|
||||
|
|
@ -75,7 +98,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
hits.truncate(12);
|
||||
|
||||
let screen = ctx.content_rect();
|
||||
let mut chosen: Option<(Option<&'static str>, &'static str)> = None;
|
||||
let mut chosen: Option<(Option<&'static str>, Option<&'static str>)> = None;
|
||||
egui::Area::new(egui::Id::new("palette"))
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(Align2::CENTER_TOP)
|
||||
|
|
@ -92,13 +115,15 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
.font(theme::regular(15.0))
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
r.request_focus();
|
||||
if r.lost_focus()
|
||||
// Enter runs the top hit. The field keeps focus (requested every frame), so
|
||||
// check while it is focused as well as when focus is lost.
|
||||
if (r.has_focus() || r.lost_focus())
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
&& let Some((_, h)) = hits.first()
|
||||
{
|
||||
chosen = Some((h.command, h.group));
|
||||
}
|
||||
r.request_focus();
|
||||
});
|
||||
ui.separator();
|
||||
for (_, h) in &hits {
|
||||
|
|
@ -107,8 +132,10 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(8.0, 9.0), vec2(18.0, 18.0)), h.icon, 17.0, t.icon);
|
||||
ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, &h.label, theme::regular(13.5), t.text);
|
||||
ui.painter().text(rect.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, h.detail, theme::regular(12.0), t.text_faint);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.ready, &h.label));
|
||||
let fg = if h.ready { t.text } else { t.text_faint };
|
||||
ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, &h.label, theme::regular(13.5), fg);
|
||||
ui.painter().text(rect.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, &h.detail, theme::regular(12.0), t.text_faint);
|
||||
if resp.clicked() {
|
||||
chosen = Some((h.command, h.group));
|
||||
}
|
||||
|
|
@ -123,8 +150,10 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
|||
if let Some((command, group)) = chosen {
|
||||
app.palette_open = false;
|
||||
app.palette_query.clear();
|
||||
app.left_open = true;
|
||||
app.left = LeftPanel::Tool(group);
|
||||
if let Some(g) = group {
|
||||
app.left_open = true;
|
||||
app.left = LeftPanel::Tool(g);
|
||||
}
|
||||
if let Some(c) = command {
|
||||
app.run_command(c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
//! Autosave and crash recovery.
|
||||
//!
|
||||
//! While documents have unsaved changes, their working file is written every
|
||||
//! `AUTOSAVE_SECS` to a recovery folder in the user's data directory. Each entry is
|
||||
//! `<key>.pdf` plus `<key>.json` (name, original path, time). Saving, discarding or closing a
|
||||
//! document removes its entry, and a clean quit leaves the folder empty. If entries are found at
|
||||
//! startup, the previous session ended unexpectedly and the app offers to recover them.
|
||||
//!
|
||||
//! Encrypted documents are stored encrypted (the working file is), so recovery never writes
|
||||
//! plaintext of a protected document to disk. The web build has no recovery store yet.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::PrintCraftApp;
|
||||
|
||||
/// How often unsaved changes are written to the recovery folder.
|
||||
pub const AUTOSAVE_SECS: f64 = 60.0;
|
||||
|
||||
/// What a recovery entry records next to the PDF bytes.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RecoveryMeta {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
/// Where the document was saved before (Save writes back there after recovery).
|
||||
pub path: Option<String>,
|
||||
/// Seconds since the Unix epoch when the snapshot was taken.
|
||||
pub saved_at: u64,
|
||||
/// The bytes are encrypted; recovering asks for the password.
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// The recovery folder.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecoveryStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RecoveryStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
/// The platform's per-user data folder: `~/Library/Application Support/PrintCraft/Recovery`
|
||||
/// (macOS), `%LOCALAPPDATA%\PrintCraft\Recovery` (Windows), or
|
||||
/// `$XDG_DATA_HOME/printcraft/recovery` / `~/.local/share/printcraft/recovery` (others).
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
env("HOME").map(|h| h.join("Library/Application Support/PrintCraft/Recovery"))
|
||||
} else if cfg!(windows) {
|
||||
env("LOCALAPPDATA").map(|d| d.join("PrintCraft").join("Recovery"))
|
||||
} else {
|
||||
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share"))).map(|d| d.join("printcraft/recovery"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Write (or replace) an entry. The PDF is written before its metadata, both atomically, so
|
||||
/// a listed entry always has complete bytes.
|
||||
pub fn write(&self, meta: &RecoveryMeta, bytes: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
crate::editing::write_atomically(&self.dir.join(format!("{}.pdf", meta.key)).to_string_lossy(), bytes)?;
|
||||
let json = serde_json::to_vec_pretty(meta).map_err(std::io::Error::other)?;
|
||||
crate::editing::write_atomically(&self.dir.join(format!("{}.json", meta.key)).to_string_lossy(), &json)
|
||||
}
|
||||
|
||||
pub fn remove(&self, key: &str) {
|
||||
let _ = std::fs::remove_file(self.dir.join(format!("{key}.json")));
|
||||
let _ = std::fs::remove_file(self.dir.join(format!("{key}.pdf")));
|
||||
}
|
||||
|
||||
/// Complete entries, newest first. Incomplete leftovers (bytes without metadata) are removed.
|
||||
pub fn list(&self) -> Vec<RecoveryMeta> {
|
||||
let Ok(dir) = std::fs::read_dir(&self.dir) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for entry in dir.flatten() {
|
||||
let path = entry.path();
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("json") => {
|
||||
let meta = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::<RecoveryMeta>(&b).ok());
|
||||
match meta {
|
||||
Some(m) if self.dir.join(format!("{}.pdf", m.key)).is_file() => out.push(m),
|
||||
_ => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("pdf") if !path.with_extension("json").is_file() => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|m| std::cmp::Reverse(m.saved_at));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn read(&self, key: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(self.dir.join(format!("{key}.pdf")))
|
||||
}
|
||||
|
||||
/// Remove every entry (clean quit, or "Discard all").
|
||||
pub fn clear(&self) {
|
||||
for m in self.list() {
|
||||
self.remove(&m.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Turn on autosave into `store`, and look for documents a previous session left behind.
|
||||
pub fn enable_recovery(&mut self, store: RecoveryStore) {
|
||||
self.recoverable = store.list();
|
||||
if !self.recoverable.is_empty() {
|
||||
self.dialog = Some(crate::Dialog::Recovery);
|
||||
}
|
||||
self.recovery = Some(store);
|
||||
}
|
||||
|
||||
/// Write snapshots of documents changed since the last autosave (called periodically).
|
||||
pub fn autosave_now(&mut self) {
|
||||
let Some(store) = self.recovery.clone() else { return };
|
||||
for snap in self.session.autosave_snapshots() {
|
||||
let key = self.recovery_keys.entry(snap.doc).or_insert_with(|| format!("{}-{}-{}", now_secs(), std::process::id(), snap.doc.0)).clone();
|
||||
let meta = RecoveryMeta { key, name: snap.name, path: snap.path, saved_at: now_secs(), encrypted: snap.encrypted };
|
||||
if let Err(e) = store.write(&meta, &snap.bytes) {
|
||||
log::warn!("autosave failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a document's recovery entry (it was saved, discarded or closed).
|
||||
pub(crate) fn forget_recovery(&mut self, doc: printcraft_engine::DocId) {
|
||||
if let (Some(store), Some(key)) = (&self.recovery, self.recovery_keys.remove(&doc)) {
|
||||
store.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Autosave when the interval has passed (called every frame).
|
||||
pub(crate) fn autosave_tick(&mut self, now: f64) {
|
||||
if self.recovery.is_none() {
|
||||
return;
|
||||
}
|
||||
if now - self.last_autosave >= AUTOSAVE_SECS {
|
||||
self.last_autosave = now;
|
||||
self.autosave_now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopen recovered documents (`keys`), as unsaved changes at their original paths.
|
||||
pub fn recover(&mut self, keys: &[String]) {
|
||||
let Some(store) = self.recovery.clone() else { return };
|
||||
for key in keys {
|
||||
let Some(meta) = self.recoverable.iter().find(|m| &m.key == key).cloned() else { continue };
|
||||
let bytes = match store.read(key) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.notify(format!("Couldn't recover {}: {e}", meta.name));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if meta.encrypted {
|
||||
// The password prompt opens it; mark it recovered once it is open.
|
||||
self.pending_recovered = Some(meta.clone());
|
||||
}
|
||||
match self.open_bytes(&meta.name, None, bytes) {
|
||||
Ok(()) if self.password_prompt.is_none() => self.finish_recovery(&meta),
|
||||
Ok(()) => {}
|
||||
Err(e) => self.notify(format!("Couldn't recover {}: {e}", meta.name)),
|
||||
}
|
||||
}
|
||||
self.recoverable.retain(|m| !keys.contains(&m.key));
|
||||
}
|
||||
|
||||
/// The most recently opened tab came from `meta`: keep its recovery entry and path.
|
||||
pub(crate) fn finish_recovery(&mut self, meta: &RecoveryMeta) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
self.session.mark_recovered(id, meta.path.clone());
|
||||
self.recovery_keys.insert(id, meta.key.clone());
|
||||
self.pending_recovered = None;
|
||||
}
|
||||
|
||||
/// Delete recovery entries the user chose not to recover.
|
||||
pub fn discard_recovered(&mut self, keys: &[String]) {
|
||||
if let Some(store) = &self.recovery {
|
||||
for k in keys {
|
||||
store.remove(k);
|
||||
}
|
||||
}
|
||||
self.recoverable.retain(|m| !keys.contains(&m.key));
|
||||
}
|
||||
|
||||
/// Clean shutdown: documents were saved or discarded, so nothing needs recovering.
|
||||
pub fn shutdown_recovery(&mut self) {
|
||||
if self.first_dirty().is_none()
|
||||
&& let Some(store) = &self.recovery
|
||||
{
|
||||
for key in self.recovery_keys.values() {
|
||||
store.remove(key);
|
||||
}
|
||||
self.recovery_keys.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
//! The command registry drives menus, shortcuts and the palette: every registered command must
|
||||
//! be implemented, disabled commands must say why, and every surface must reach the same action.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use printcraft_engine::commands::COMMANDS;
|
||||
use printcraft_ui_egui::{Dialog, PrintCraftApp};
|
||||
|
||||
fn fixture(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, PrintCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Commands that open a native file picker can't run headless.
|
||||
const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as"];
|
||||
|
||||
#[test]
|
||||
fn every_registered_command_is_implemented() {
|
||||
for spec in COMMANDS {
|
||||
if PICKERS.contains(&spec.id) {
|
||||
continue;
|
||||
}
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
|
||||
// Give undo/redo something to do.
|
||||
app.apply_edit(printcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
if spec.id == "edit.redo" {
|
||||
app.undo();
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("printcraft-cmd-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned());
|
||||
assert!(app.execute(spec.id), "{} is registered but not implemented (or wrongly disabled)", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_commands_explain_themselves() {
|
||||
let mut app = PrintCraftApp::new();
|
||||
assert!(!app.execute("file.save"));
|
||||
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Open a document first"));
|
||||
app.open_bytes("doc.pdf", None, fixture(2)).unwrap();
|
||||
assert!(!app.execute("edit.undo"));
|
||||
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Nothing to undo"));
|
||||
assert!(!app.execute("no.such.command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_run_registered_commands() {
|
||||
let mut h = harness();
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::D);
|
||||
h.run_steps(2);
|
||||
assert!(matches!(h.state().dialog, Some(Dialog::Properties(_))));
|
||||
h.state_mut().dialog = None;
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::K);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().palette_open);
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::CTRL, Key::H);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().mode, printcraft_ui_egui::Mode::Read);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_palette_lists_commands_with_shortcuts_and_runs_them() {
|
||||
let mut h = harness();
|
||||
h.state_mut().set_option("palette", "rotate pages clockwise").unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Rotate pages clockwise");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(!app.palette_open, "Enter runs the top hit and closes the palette");
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
assert_eq!(doc.info.pages[0].rotation, 90);
|
||||
assert_eq!(doc.can_undo(), Some("Rotate page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pages_menu_comes_from_the_registry() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Pages ⏵").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Delete pages").click();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcuts_dialog_lists_the_real_bindings() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("help.shortcuts");
|
||||
h.run_steps(3);
|
||||
let mac = cfg!(target_os = "macos");
|
||||
h.get_by_label(if mac { "⇧⌘S" } else { "Ctrl+Shift+S" });
|
||||
h.get_by_label("Save as");
|
||||
}
|
||||
|
|
@ -301,3 +301,167 @@ fn edit_menu_names_the_step_to_undo() {
|
|||
assert_eq!(doc.can_redo(), Some("Rotate page"));
|
||||
assert_eq!(doc.info.pages[0].rotation, 0);
|
||||
}
|
||||
|
||||
// ── Combine / insert from file / extract / split ──────────────────────────────────────────────
|
||||
|
||||
fn texts_of(app: &PrintCraftApp, tab: usize) -> Vec<String> {
|
||||
let doc = app.session.get(app.views[tab].id).unwrap();
|
||||
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
(0..r.page_count())
|
||||
.map(|p| {
|
||||
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_files_opens_a_new_unsaved_tab() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.use_files(printcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(2)), ("two.pdf".into(), fixture(1))]);
|
||||
});
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 2);
|
||||
assert_eq!(app.active, Some(1));
|
||||
assert_eq!(texts_of(app, 1), ["Page 1", "Page 2", "Page 1"]);
|
||||
let doc = app.session.get(app.views[1].id).unwrap();
|
||||
assert_eq!(doc.name, "Combined.pdf");
|
||||
assert!(doc.dirty && doc.path.is_none(), "unsaved until the user saves it");
|
||||
assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::<Vec<_>>(), ["one", "two"]);
|
||||
h.get_by_label("Combined.pdf (edited)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_button_copies_selected_pages_to_a_new_tab() {
|
||||
let mut h = organize(3);
|
||||
h.get_by_label("Page 2").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Extract pages to a new document").click();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 2);
|
||||
assert_eq!(texts_of(app, 1), ["Page 2", "Page 3"]);
|
||||
assert_eq!(texts_of(app, 0).len(), 3, "the original is unchanged");
|
||||
assert!(!app.session.get(app.views[0].id).unwrap().dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_file_goes_after_the_selection_and_undoes() {
|
||||
let mut h = organize(2);
|
||||
h.get_by_label("Page 1").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().use_files(printcraft_ui_egui::FilePurpose::InsertPages, vec![("extra.pdf".into(), fixture(2))]);
|
||||
h.run_steps(3);
|
||||
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "Page 2"]);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
|
||||
h.run_steps(3);
|
||||
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_dialog_writes_one_file_per_part() {
|
||||
let dir = temp_path("split-count");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let d = dir.to_string_lossy().into_owned();
|
||||
let mut h = organize(3);
|
||||
h.state_mut().export_dir_override = Some(d);
|
||||
h.get_by_label("Split into files…").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Creates 3 files from 3 pages");
|
||||
h.get_by_label("Split").click();
|
||||
h.run_steps(3);
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["doc (page 1).pdf", "doc (page 2).pdf", "doc (page 3).pdf"]);
|
||||
for name in names {
|
||||
let info = printcraft_render::inspect(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap()), None).unwrap();
|
||||
assert_eq!(info.pages.len(), 1);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_before_selected_pages() {
|
||||
let dir = temp_path("split-sel");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = organize(4);
|
||||
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
|
||||
h.state_mut().views[0].select_pages(&[2]);
|
||||
h.state_mut().split_draft.at_selection = true;
|
||||
h.state_mut().run_command("page.split");
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Creates 2 files from 4 pages");
|
||||
h.get_by_label("Split").click();
|
||||
h.run_steps(3);
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
// ── Encrypted documents ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
|
||||
let mut doc = printcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
|
||||
doc.set_encryption(&printcraft_cos::NewEncryption {
|
||||
algorithm: printcraft_cos::Algorithm::Aes256,
|
||||
user_password: user,
|
||||
owner_password: owner,
|
||||
permissions,
|
||||
encrypt_metadata: true,
|
||||
seed: [4; 32],
|
||||
})
|
||||
.unwrap();
|
||||
printcraft_cos::write_full(&doc, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_prompt_opens_and_security_tab_reports_the_details() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("secret.pdf", None, protected("pw", "owner", -1)).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_label_contains("is protected");
|
||||
let field = h.get_by_role(Role::PasswordInput);
|
||||
field.focus();
|
||||
field.type_text("pw");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
h.state_mut().set_option("dialog", "properties").unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Security").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("AES, 256-bit");
|
||||
h.get_by_label("User password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_documents_show_a_notice_and_block_page_changes() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); // opens without a password
|
||||
app.set_option("organize", "on").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_label_contains("This document is secured");
|
||||
h.get_by_label("Page 1").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Delete pages (Delete)").click();
|
||||
h.key_press(Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert_eq!(texts_of(h.state(), 0).len(), 2, "page changes are blocked");
|
||||
assert!(!dirty(&h));
|
||||
h.get_by_label("Security settings").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_label("Not allowed").count() >= 4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
//! Autosave and crash recovery: a session is "crashed" by dropping the app without a clean
|
||||
//! quit, and a new session must offer, recover or discard what was autosaved.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use printcraft_engine::Edit;
|
||||
use printcraft_ui_egui::{PrintCraftApp, RecoveryStore};
|
||||
|
||||
fn fixture(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Confidential {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn store(tag: &str) -> RecoveryStore {
|
||||
let dir = std::env::temp_dir().join(format!("printcraft-recovery-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
RecoveryStore::new(dir)
|
||||
}
|
||||
|
||||
fn files_in(s: &RecoveryStore) -> usize {
|
||||
std::fs::read_dir(s.dir()).map(|d| d.count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A session that edits a document, autosaves, and then "crashes" (is dropped).
|
||||
fn crashed_session(s: &RecoveryStore, bytes: Vec<u8>, password: Option<&str>, path: Option<&str>) {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.enable_recovery(s.clone());
|
||||
match password {
|
||||
Some(pw) => {
|
||||
app.open_bytes("work.pdf", path.map(str::to_string), bytes).unwrap();
|
||||
app.submit_password(Some(pw.to_string()));
|
||||
}
|
||||
None => app.open_bytes("work.pdf", path.map(str::to_string), bytes).unwrap(),
|
||||
}
|
||||
assert!(app.apply_edit(Edit::DeletePages { pages: vec![0] }));
|
||||
app.autosave_now();
|
||||
// Dropped without a clean quit: the recovery entry stays.
|
||||
}
|
||||
|
||||
fn harness(s: RecoveryStore) -> Harness<'static, PrintCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.enable_recovery(s);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crashed_session_is_offered_and_recovered_unsaved() {
|
||||
let s = store("recover");
|
||||
crashed_session(&s, fixture(3), None, Some("/docs/work.pdf"));
|
||||
assert_eq!(s.list().len(), 1);
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label("Recover unsaved documents?");
|
||||
h.get_by_label("work.pdf");
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(4);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
assert!(doc.dirty, "recovered changes are unsaved");
|
||||
assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "Save goes back to the original file");
|
||||
assert_eq!(doc.info.pages.len(), 2, "the edit survived the crash");
|
||||
assert_eq!(s.list().len(), 1, "the entry stays until the document is saved or closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarding_removes_the_autosaves() {
|
||||
let s = store("discard");
|
||||
crashed_session(&s, fixture(2), None, None);
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label("Discard").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views.is_empty());
|
||||
assert_eq!(files_in(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents() {
|
||||
let s = store("save");
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.enable_recovery(s.clone());
|
||||
app.open_bytes("a.pdf", None, fixture(2)).unwrap();
|
||||
app.autosave_now();
|
||||
assert_eq!(files_in(&s), 0, "no changes, no autosave");
|
||||
app.apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
app.autosave_now();
|
||||
assert_eq!(s.list().len(), 1);
|
||||
let out = std::env::temp_dir().join(format!("printcraft-recovery-saved-{}.pdf", std::process::id()));
|
||||
app.save_override = Some(out.to_string_lossy().into_owned());
|
||||
assert!(app.save_active(printcraft_ui_egui::SaveTarget::InPlace));
|
||||
assert_eq!(files_in(&s), 0, "saved: nothing to recover");
|
||||
// Edit again, autosave, then close and discard.
|
||||
app.apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
app.autosave_now();
|
||||
assert_eq!(s.list().len(), 1);
|
||||
app.close_tab(0);
|
||||
assert_eq!(files_in(&s), 0, "closed: nothing to recover");
|
||||
let _ = std::fs::remove_file(out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_cleanly_leaves_nothing_behind() {
|
||||
let s = store("quit");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe({
|
||||
let s = s.clone();
|
||||
move |_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.enable_recovery(s);
|
||||
app.open_bytes("q.pdf", None, fixture(2)).unwrap();
|
||||
app
|
||||
}
|
||||
});
|
||||
h.run_steps(3);
|
||||
h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
h.state_mut().autosave_now();
|
||||
assert_eq!(s.list().len(), 1);
|
||||
// Quit → "Don't save" → the app closes and the autosave is removed.
|
||||
h.state_mut().close_request = Some(printcraft_ui_egui::CloseRequest::Quit);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Don't save").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().views.is_empty());
|
||||
h.state_mut().shutdown_recovery();
|
||||
assert_eq!(files_in(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() {
|
||||
let mut doc = printcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
|
||||
doc.set_encryption(&printcraft_cos::NewEncryption {
|
||||
algorithm: printcraft_cos::Algorithm::Aes256,
|
||||
user_password: "pw",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [2; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = printcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
let s = store("encrypted");
|
||||
crashed_session(&s, bytes, Some("pw"), None);
|
||||
let meta = s.list().remove(0);
|
||||
assert!(meta.encrypted);
|
||||
let saved = s.read(&meta.key).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&saved).contains("Confidential"), "no plaintext on disk");
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label_contains("password-protected");
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(3);
|
||||
let field = h.get_by_role(Role::PasswordInput);
|
||||
field.focus();
|
||||
field.type_text("pw");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let d = app.session.get(app.views[0].id).unwrap();
|
||||
assert!(d.dirty);
|
||||
assert_eq!(d.info.pages.len(), 1);
|
||||
let _ = Modifiers::NONE;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_entries_are_ignored_and_cleaned_up() {
|
||||
let s = store("partial");
|
||||
std::fs::create_dir_all(s.dir()).unwrap();
|
||||
std::fs::write(s.dir().join("orphan.pdf"), b"%PDF-1.7").unwrap();
|
||||
std::fs::write(s.dir().join("broken.json"), b"{ not json").unwrap();
|
||||
assert!(s.list().is_empty());
|
||||
assert_eq!(files_in(&s), 0);
|
||||
}
|
||||
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 689 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 684 KiB After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 688 KiB |
|
|
@ -6,6 +6,7 @@ mod assets;
|
|||
mod demo_pdf;
|
||||
mod gates;
|
||||
mod layers;
|
||||
mod screenshots;
|
||||
|
||||
type Command = fn(&[String]) -> anyhow::Result<()>;
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ const COMMANDS: &[(&str, &str, Command)] = &[
|
|||
("corpus", "Fetch test corpora into corpus/ (git-ignored): pdf.js test PDFs", gates::corpus),
|
||||
("check", "Robustness sweep over corpus/ with printcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check),
|
||||
("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle),
|
||||
("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run),
|
||||
("demo-pdf", "Build dist/demo/printcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
//! `cargo xtask screenshots [name…]`: regenerate the README screenshots in `docs/images/`.
|
||||
//!
|
||||
//! Every image is a headless render of the real app (`examples/shot.rs`) showing the showcase
|
||||
//! PDF. That PDF is built only from the OFL fonts and generated artwork in ATTRIBUTION.toml, so
|
||||
//! the screenshots are contributor-original and policy-compliant (AGENTS.md §1). Afterwards the
|
||||
//! `docs/images/*.png` entries in ATTRIBUTION.toml are rewritten with fresh hashes, and
|
||||
//! ATTRIBUTION.md is regenerated.
|
||||
//!
|
||||
//! The document is opened by a *relative* path so no local directory names (user names) can
|
||||
//! appear in a screenshot.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// (file stem, title, what it shows, shot arguments after the PDF path).
|
||||
pub const SCENES: &[(&str, &str, &str, &[&str])] = &[
|
||||
("printcraft-viewer", "Viewer with comments", "README hero", &["--panel", "comments", "--zoom", "72"]),
|
||||
(
|
||||
"printcraft-dark",
|
||||
"Dark theme",
|
||||
"README: themes",
|
||||
&["--theme", "dark", "--panel", "comments", "--page", "11", "--zoom", "80", "--notice", "off"],
|
||||
),
|
||||
("printcraft-organize", "Organize pages", "README: organize", &["--organize", "on", "--panel", "none", "--select", "5,6,7"]),
|
||||
("printcraft-find", "Find text", "README: search", &["--find", "type", "--page", "4", "--zoom", "90", "--panel", "none", "--notice", "off"]),
|
||||
("printcraft-bookmarks", "Bookmarks", "README: navigation", &["--panel", "bookmarks", "--page", "7", "--zoom", "75", "--notice", "off"]),
|
||||
("printcraft-forms", "Interactive form", "README: forms", &["--page", "13", "--fields", "on", "--zoom", "80", "--panel", "fields"]),
|
||||
("printcraft-layers", "Layers", "README: layers", &["--panel", "layers", "--page", "12", "--zoom", "75", "--notice", "off"]),
|
||||
(
|
||||
"printcraft-scripts",
|
||||
"Scripts of the world",
|
||||
"README: rendering",
|
||||
&["--page", "7", "--zoom", "125", "--left", "closed", "--panel", "none", "--notice", "off"],
|
||||
),
|
||||
("printcraft-properties", "Document properties", "README: metadata", &["--dialog", "properties", "--notice", "off"]),
|
||||
("printcraft-palette", "Command palette", "README: tools", &["--palette", "page", "--notice", "off"]),
|
||||
("printcraft-split", "Split dialog", "README: split", &["--organize", "on", "--panel", "none", "--dialog", "split", "--notice", "off"]),
|
||||
("printcraft-tools", "All tools", "README: tool catalogue", &["--tools", "expanded", "--home", "on"]),
|
||||
(
|
||||
"printcraft-twoup",
|
||||
"Two-up reading",
|
||||
"README: layouts",
|
||||
&["--layout", "two-up", "--mode", "read", "--page", "4", "--theme", "dark", "--notice", "off"],
|
||||
),
|
||||
];
|
||||
|
||||
const PDF: &str = "dist/demo/printcraft-showcase.pdf";
|
||||
|
||||
pub fn run(args: &[String]) -> Result<()> {
|
||||
let root = crate::assets::root();
|
||||
if !root.join(PDF).is_file() {
|
||||
println!("screenshots: building the showcase PDF first");
|
||||
crate::demo_pdf::run(&[])?;
|
||||
}
|
||||
let status = Command::new(env!("CARGO"))
|
||||
.args(["build", "--release", "-p", "printcraft-ui-egui", "--example", "shot"])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.context("building the shot example")?;
|
||||
if !status.success() {
|
||||
bail!("building the shot example failed");
|
||||
}
|
||||
let target = std::env::var("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|_| root.join("target"));
|
||||
let shot = target.join("release/examples").join(if cfg!(windows) { "shot.exe" } else { "shot" });
|
||||
std::fs::create_dir_all(root.join("docs/images"))?;
|
||||
for (name, _, _, scene) in SCENES {
|
||||
if !args.is_empty() && !args.iter().any(|a| a == name) {
|
||||
continue;
|
||||
}
|
||||
let out = format!("docs/images/{name}.png");
|
||||
let status = Command::new(&shot)
|
||||
.arg(&out)
|
||||
.arg(PDF)
|
||||
.args(["--size", "1440x900", "--scale", "2", "--width", "1600"])
|
||||
.args(*scene)
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.with_context(|| format!("running shot for {name}"))?;
|
||||
if !status.success() {
|
||||
bail!("screenshot {name} failed");
|
||||
}
|
||||
}
|
||||
update_attribution(&root)?;
|
||||
crate::assets::run(&["--write".to_string()])
|
||||
}
|
||||
|
||||
/// Replace the `docs/images/` entries of ATTRIBUTION.toml with one per screenshot on disk.
|
||||
fn update_attribution(root: &Path) -> Result<()> {
|
||||
let path = root.join("ATTRIBUTION.toml");
|
||||
let text = std::fs::read_to_string(&path)?;
|
||||
// Split into the header and `[[…]]` blocks; drop existing screenshot blocks.
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for (i, block) in text.split("\n[[").enumerate() {
|
||||
let block = if i == 0 { block.to_string() } else { format!("[[{block}") };
|
||||
if block.starts_with("[[asset]]") && block.contains("path = \"docs/images/") {
|
||||
continue;
|
||||
}
|
||||
parts.push(block);
|
||||
}
|
||||
let mut out = parts.join("\n");
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
let mut images: Vec<_> = std::fs::read_dir(root.join("docs/images"))?.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
images.sort();
|
||||
for file in images.iter().filter(|f| f.ends_with(".png")) {
|
||||
let stem = file.trim_end_matches(".png");
|
||||
let (title, usage) = SCENES.iter().find(|(n, ..)| *n == stem).map(|(_, t, u, _)| (*t, *u)).unwrap_or(("PrintCraft screenshot", "README"));
|
||||
let bytes = std::fs::read(root.join("docs/images").join(file))?;
|
||||
out.push_str(&format!(
|
||||
"\n[[asset]]\npath = \"docs/images/{file}\"\ntitle = \"PrintCraft screenshot: {title}\"\nauthor = \"PrintCraft contributors (generated by `cargo xtask screenshots`)\"\nsource = \"this repository\"\nlicence = \"MIT OR Apache-2.0\"\nlicence_file = \"LICENSE-MIT\"\nkind = \"image\"\nusage = \"{usage}. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts\"\nsha256 = \"{}\"\n",
|
||||
crate::assets::sha256_hex(&bytes)
|
||||
));
|
||||
}
|
||||
std::fs::write(&path, out)?;
|
||||
Ok(())
|
||||
}
|
||||