Merge remote-tracking branch 'origin/main' into dmg-background

# Conflicts:
#	ATTRIBUTION.md
This commit is contained in:
Brandon Thomas committed 2026-10-09 09:21:35 -04:00
commit 37542ad9db
110 files changed
+5128 -351

No files matched your search

+4 -2
View File
@@ -23,12 +23,14 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds,
# and release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
# Linux; macOS and Windows test the build without it. See craftrules standards/fonts.md.
# Linux; Windows tests the build without it. See craftrules standards/fonts.md.
- name: Check out craft-fonts (Linux)
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+2 -1
View File
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (253)
## In this repository (254)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -16,6 +16,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+20 -9
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "50ec1133889f3a3948a3901a9fa40b3499f3c549b605e55848138483946d74da"
sha256 = "8444a3bd135d61feb6c5b559a9eb0babb50de2ebbd1d86b1e87ccb064e2d3f0b"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3746a7d87fb98c9f3aeb41c63ce50371c42dfebca64b67ccbb599abb36a93ba8"
sha256 = "8771d80405b8fba9da4a909fdd97b760f6b25a822807ca83ff20dc5cec83a73c"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2c09a121e493fb123d9f886529f62268119ca580ad5d5300eca61d4a39216827"
sha256 = "bf1ab8079af725264f9c4298b9737359ba831170dcba76a6bd39228239c87e83"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0ccf62e34424981d5980eeaee8799db2653a4c186807df143326ef9881959006"
sha256 = "b23b03cf4ac163e7a63e49a0900a125507c05ad4fc7930468b859acf32b09ecd"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0b579afad6e52b98cef5e61e07e238d9cf76cc7f295395f7e1c855400e5fbaf9"
sha256 = "1b3371f239ca89a658e2c9b80cc070be5cd941dd1ecd3e7a247696716f25721d"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "956753e05b5a9e1d4c4a63ee57667cc9fa5bb95288c3d46c6b15435b83d6ed8d"
sha256 = "dbf725ffc9177a435e894f4405834e87c70b6fe31a34f1be533c60c356510b97"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e8b96edd718a1cd212b7440c4ab100f2555c97dccb0cda289bdb1775d5f3aa61"
sha256 = "f7a5ab0bcbdb8641b1bd4ae25db54ef187757dde3e4fe372e433a9e5c0fc4f3a"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -96,7 +96,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "136a9ae206c4ee5aad29d248613c568799080b4b3ee9cd197233072ee9a2983a"
sha256 = "259f795a3cdc032aa670693d2ebff010c5dd3bcf57dda11548d5bd46e78969b8"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
title = "Bulgarian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "121c3881bf6ba69682ca6acae32303e3bf425479a8c2cb3e875c6598f5ffef7d"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -107,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d2e7114c70da3c52200cf68e9023dc32ff462439795784e49c2ae771531240e2"
sha256 = "cdcdba828efea9d402cb9ce8be2b52ab1d58c8f7837d75d3b9c86f2a05e7fb2e"
[[asset]]
path = "assets/icons/accessibility.svg"
Generated
+3
View File
@@ -3477,6 +3477,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
]
@@ -3744,6 +3745,7 @@ dependencies = [
name = "pdfcraft-render"
version = "0.4.0"
dependencies = [
"bytemuck",
"getrandom 0.4.3",
"hayro",
"hayro-syntax",
@@ -3751,6 +3753,7 @@ dependencies = [
"log",
"lopdf",
"pdfcraft-fonts",
"skrifa 0.44.0",
"thiserror 2.0.21",
]
+3 -1
View File
@@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
<table>
<tr>
@@ -172,6 +172,8 @@ Open **Organize pages** to see every page at once:
- *Atomic:* the file is written to a temporary copy, then swapped in.
- *Verified:* independently checked with qpdf.
When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view.
Unsaved documents carry a dot on their tab, and closing or quitting asks before anything is lost. Changes are autosaved every minute. If PdfCraft ever quits unexpectedly, it offers to recover your work the next time it opens. Encrypted documents stay encrypted on disk.
</td>
+5 -1
View File
@@ -90,7 +90,7 @@ 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 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), 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>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, 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>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| 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 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | 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, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
@@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
@@ -136,6 +138,8 @@ 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-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
+3
View File
@@ -224,6 +224,9 @@ fn main() -> eframe::Result {
app.notify(e);
}
} else {
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
app.reopen_last_files(&files);
for f in files {
app.open_path(&f);
}
+5
View File
@@ -59,3 +59,8 @@ sync_github_name = true
[people.amitabhthakur]
sync_github_name = true
[people.chent11]
real_name = "Tao Chen"
display_name = "Tao Chen"
sync_github_name = false
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
# pdfcraft-annot
Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends only on `pdfcraft-cos`.
Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, `pdfcraft-fonts` and `pdfcraft-model`.
## What it does
+20 -3
View File
@@ -200,6 +200,12 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
Stream::flate(d, content)
}
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
/// when the signature is added to a turned page): 0, 90, 180 or 270.
pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
@@ -444,11 +450,14 @@ pub fn build(d: &Dict) -> Option<Stream> {
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
// the rectangle.
// the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
let place = if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
let place = if image {
format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
} else {
let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
@@ -458,7 +467,15 @@ pub fn build(d: &Dict) -> Option<Stream> {
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
res.set(b"XObject".to_vec(), Object::Dict(xo));
return Some(form(rect, c.as_bytes(), res));
let mut stream = form(rect, c.as_bytes(), res);
let turn = picture_turn(d);
if image && turn != 0 {
// The page's view matrix without its offset: display axes in user space.
let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect()));
}
return Some(stream);
}
// Only PdfCraft's own Fill & Sign marks are drawn here.
let name = d.name(b"Name")?;
+18
View File
@@ -677,6 +677,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result<Opti
.flatten())
}
/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back
/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by
/// another app. The picture appears turned by the page's current rotation less this.
pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64, AnnotError> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
}
// ── building ────────────────────────────────────────────────────────────────────────────────
/// Annotation flags (§12.5.3).
@@ -968,6 +978,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
// An image signature's rectangle is in user space; on a turned page its appearance is
// turned back (see `appearance::build`), so it reads upright as displayed.
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if turn != 0 {
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
}
}
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
d.set(b"C".to_vec(), rgb(style.color));
+1 -1
View File
@@ -23,7 +23,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
- Pages and positions are **1-based**. Rectangles are PDF points with the origin at the top-left of the displayed page.
- Unknown arguments are rejected, so typos fail loudly.
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
+2 -1
View File
@@ -109,6 +109,7 @@ impl Automation {
};
Ok(vec![
Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation,
"image_rotation": (i64::from(info.rotation) - preview.turn).rem_euclid(360),
"layer": layer, "opacity": preview.opacity, "dpi": dpi })),
image,
])
@@ -386,7 +387,7 @@ impl Automation {
let path = self.resolve(path, false)?;
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let edit = image.edit(page, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
let size = 10.0;
+1 -1
View File
@@ -642,7 +642,7 @@ pub fn tools() -> Vec<ToolDef> {
})),
&["doc"],
)),
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t(
+51
View File
@@ -1485,6 +1485,7 @@ fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() {
assert_eq!(meta["layer"], "background");
assert_eq!(image_meta["layer"], "image");
assert_eq!(meta["rotation"], 90);
assert_eq!(meta["image_rotation"], 0, "placed upright as displayed on the turned page");
assert_eq!(meta["opacity"], 0.5);
assert_eq!((*width, *height), (120, 40));
assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain");
@@ -2040,6 +2041,56 @@ fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() {
}
}
/// An image signature is drawn upright as displayed, and anchored at the displayed point asked
/// for, on every `/Rotate`: its picture is placed in user space, which the page turns, so the
/// appearance is counter-rotated and its box is found in user space.
#[test]
fn image_signatures_stay_upright_on_rotated_pages() {
let dir = workdir("image-upright");
// 80 x 40 px, one colour per quadrant: red and green above, blue and yellow below.
let quadrants = image::RgbaImage::from_fn(80, 40, |x, y| match (x < 40, y < 20) {
(true, true) => image::Rgba([255, 0, 0, 255]),
(false, true) => image::Rgba([0, 255, 0, 255]),
(true, false) => image::Rgba([0, 0, 255, 255]),
(false, false) => image::Rgba([255, 255, 0, 255]),
});
quadrants.save(dir.join("quadrants.png")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
}
for page in 1..=4 {
// 64 x 32 pt from x = 60 as displayed, centred on y = 140.
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [60, 140], "path": "quadrants.png" }));
}
let (white, red, green, blue, yellow) = ([255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]);
let check = |a: &mut Automation, doc: u64| {
for page in 1..=4 {
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
let Content::Png { data, .. } = &output[0] else { panic!() };
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
for (x, y, want, what) in [
(76, 132, red, "signature top-left"),
(108, 132, green, "signature top-right"),
(76, 148, blue, "signature bottom-left"),
(108, 148, yellow, "signature bottom-right"),
(56, 140, white, "left of the signature"),
(128, 140, white, "right of the signature"),
(92, 120, white, "above the signature"),
(92, 160, white, "below the signature"),
] {
let got = pixels.get_pixel(x, y).0;
assert!(got[..3].iter().zip(want).all(|(g, w)| g.abs_diff(w) < 12), "page {page}, {what} at ({x}, {y}): {got:?}, want {want:?}");
}
}
};
check(&mut a, doc);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
check(&mut a, reopened);
}
#[test]
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
let dir = workdir("comment-polish");
+64 -3
View File
@@ -223,19 +223,29 @@ impl Stream {
pub fn decoded_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let chain = self.filters();
if chain.is_empty() {
return Ok(self.raw.as_ref().clone());
return self.raw_within(max);
}
pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
}
/// Decoded data; any corruption is an error.
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
let chain = self.filters();
if chain.is_empty() {
return Ok(self.raw.as_ref().clone());
return self.raw_within(MAX_DECODED);
}
pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
}
fn raw_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let max = max.min(MAX_DECODED);
// An empty decoder chain still produces an owned output buffer: check its limit
// before cloning, including streams whose /Crypt filter was applied on load.
if self.raw.len() > max {
return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
}
Ok(self.raw.as_ref().clone())
}
}
#[derive(Clone, Debug, PartialEq)]
@@ -391,4 +401,55 @@ mod tests {
assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]);
assert_eq!(d.int(b"B"), Some(3));
}
#[test]
fn raw_streams_refuse_decoding_past_a_caller_limit() {
let stream = Stream::from_raw(Dict::new(), b"abc".to_vec());
for max in [0, 2] {
assert!(matches!(stream.decoded_within(max), Err(CosError::Filter(_))));
}
}
#[test]
fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(Vec::new()));
let stream = Stream::from_raw(dict, b"abc".to_vec());
assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_))));
}
#[test]
fn crypt_only_streams_refuse_decoding_past_a_caller_limit() {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("Crypt"));
let mut parms = Dict::new();
parms.set(b"Name".to_vec(), Object::name("Identity"));
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let stream = Stream::from_raw(dict, b"abc".to_vec());
assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_))));
}
#[test]
fn identity_streams_preserve_bytes_within_a_caller_limit() {
let mut array = Dict::new();
array.set(b"Filter".to_vec(), Object::Array(Vec::new()));
let mut crypt = Dict::new();
crypt.set(b"Filter".to_vec(), Object::name("Crypt"));
for dict in [Dict::new(), array, crypt] {
let stream = Stream::from_raw(dict.clone(), b"abc".to_vec());
assert_eq!(stream.decoded_within(3).unwrap(), b"abc");
assert_eq!(stream.decoded_within(usize::MAX).unwrap(), b"abc");
assert_eq!(stream.decoded().unwrap(), b"abc");
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
assert!(Stream::from_raw(dict, Vec::new()).decoded_within(0).unwrap().is_empty());
}
}
#[test]
fn filtered_streams_preserve_the_caller_limit() {
let stream = Stream::flate(Dict::new(), b"abc");
assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_))));
assert_eq!(stream.decoded_within(3).unwrap(), b"abc");
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
}
}
+21 -13
View File
@@ -181,16 +181,17 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
let mut doc = doc.clone();
stamp_mod_date(&mut doc, opts);
let original = doc.bytes().clone();
let mut out = Vec::with_capacity(original.len() + 4096);
out.extend_from_slice(&original);
if !matches!(out.last(), Some(b'\n' | b'\r')) {
out.push(b'\n');
}
// The revision is written on its own and joined to the original at the end, so the result is
// allocated once at its exact size: growing a buffer that holds a large original past a
// guessed capacity would copy the whole file again and keep up to as much again unused.
let newline = !matches!(original.last(), Some(b'\n' | b'\r'));
let base = (original.len() + usize::from(newline)) as u64;
let mut out = Vec::new();
let mut offsets: BTreeMap<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
for (num, generation, obj) in doc.overlay_entries().collect::<Vec<_>>() {
match obj {
Some(o) => {
offsets.insert(num, (out.len() as u64, generation, true));
offsets.insert(num, (base + out.len() as u64, generation, true));
write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out);
}
None => {
@@ -210,16 +211,22 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
if as_stream {
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
write_xref_stream(&mut out, &mut trailer, rows, size);
write_xref_stream(&mut out, base, &mut trailer, rows, size);
} else {
trailer.set(b"Size".to_vec(), Object::Int(size as i64));
let xref_at = out.len();
let xref_at = base + out.len() as u64;
write_xref_table(&mut out, &offsets, prev.is_none());
out.extend_from_slice(b"trailer\n");
write_dict(&trailer, &mut out);
let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
}
Ok(out)
let mut file = Vec::with_capacity(original.len() + usize::from(newline) + out.len());
file.extend_from_slice(&original);
if newline {
file.push(b'\n');
}
file.extend_from_slice(&out);
Ok(file)
}
/// Write only reachable objects, renumbered from 1, with a classic cross-reference table.
@@ -317,7 +324,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
ensure_id(&mut trailer, opts, doc.bytes());
}
if opts.object_streams {
write_xref_stream(&mut out, &mut trailer, rows, next);
write_xref_stream(&mut out, 0, &mut trailer, rows, next);
} else {
trailer.set(b"Size".to_vec(), Object::Int(next as i64));
let offsets: BTreeMap<u32, (u64, u16, bool)> =
@@ -374,9 +381,10 @@ fn write_xref_table(out: &mut Vec<u8>, offsets: &BTreeMap<u32, (u64, u16, bool)>
}
/// A cross-reference stream section (§7.5.8) that takes object number `num`, ending the file.
/// Rows are Flate-compressed with the PNG Up predictor, and field widths fit the largest value.
fn write_xref_stream(out: &mut Vec<u8>, trailer: &mut Dict, mut rows: BTreeMap<u32, Row>, num: u32) {
let xref_at = out.len() as u64;
/// `out` holds the file from byte offset `base` on. Rows are Flate-compressed with the PNG Up
/// predictor, and field widths fit the largest value.
fn write_xref_stream(out: &mut Vec<u8>, base: u64, trailer: &mut Dict, mut rows: BTreeMap<u32, Row>, num: u32) {
let xref_at = base + out.len() as u64;
rows.insert(num, Row::InFile(xref_at, 0));
if trailer.get(b"Prev").is_none() {
rows.entry(0).or_insert(Row::Free(65535));
+26 -1
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
/// Four pages, a shared font, document info with a non-ASCII title.
fn fixture() -> Vec<u8> {
@@ -87,6 +87,31 @@ fn incremental_saves_stack_on_an_xref_stream_file() {
assert_eq!(hayro_pages(&updated), 4);
}
/// The revision is joined to the original once, at its exact size. A revision of more than a few
/// kilobytes used to outgrow the buffer: the whole original was copied a second time, and the
/// result kept up to as much again unused for as long as it was the document's working bytes.
#[test]
fn incremental_saves_are_allocated_at_their_exact_size() {
let table = Arc::new(fixture());
let stream = Arc::new(write_full(&Document::open(table.clone()).unwrap(), &SaveOptions::default()).unwrap());
let no_newline = Arc::new(fixture().trim_ascii_end().to_vec());
for original in [table, stream, no_newline] {
let mut doc = Document::open(original.clone()).unwrap();
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.clone()) }));
let updated = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(updated.capacity(), updated.len());
assert!(updated.starts_with(&original), "an incremental save only appends");
let back = Document::open(Arc::new(updated.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
match &*back.get(added) {
Object::Stream(s) => assert_eq!(*s.raw, data),
other => panic!("{other:?}"),
}
assert_eq!(hayro_pages(&updated), 4);
}
}
#[test]
fn qpdf_accepts_object_stream_output() {
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {
+22
View File
@@ -414,6 +414,28 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() {
}
}
#[test]
fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") {
return;
}
let replacement = "Привет, world";
let has_all = |f: &pdfcraft_fonts::CraftFont| replacement.chars().all(|ch| pdfcraft_fonts::japanese_glyph_from(f, ch).is_ok());
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.covers("Jpan") && has_all(f)) {
eprintln!("skipping: no Japanese fallback face has Cyrillic");
return;
}
for paragraph in [false, true] {
let mut doc = styled_text_page("Times-Roman");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") {
+26 -2
View File
@@ -570,9 +570,13 @@ fn no_japanese_font() -> EditError {
)
}
/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first.
fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> {
faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied())
}
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crate::added::Family, bold: bool) -> Result<Type3Fallback, EditError> {
let face = pdfcraft_fonts::document_japanese_font_for_style(family == crate::added::Family::Times, bold).ok_or_else(no_japanese_font)?;
let family = face.family;
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
let mut chars = Vec::new();
for ch in text.chars() {
if !chars.contains(&ch) {
@@ -585,6 +589,10 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
if chars.is_empty() {
return Err(EditError::Invalid("replacement text is empty".into()));
}
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
// character. When none has them all, the best face reports the character it lacks.
let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?;
let family = face.family;
let mut codes = Vec::with_capacity(chars.len());
let mut charprocs = Dict::new();
let mut widths = Vec::with_capacity(chars.len());
@@ -1138,4 +1146,20 @@ mod tests {
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]);
}
#[test]
fn fallback_face_moves_on_when_the_best_face_lacks_a_character() {
static JPAN: &[&str] = &["Jpan"];
let mincho = CraftFont { family: "Mincho", style: "Regular", scripts: JPAN, bytes: b"" };
let gothic = CraftFont { family: "Gothic", style: "Regular", scripts: JPAN, bytes: b"" };
let faces = [&mincho, &gothic];
// Only the Gothic face has the Cyrillic letter.
let has = |face: &CraftFont, ch: char| ch.is_ascii() || face.family == "Gothic";
assert_eq!(fallback_face(&faces, &['a', 'ф'], has).map(|f| f.family), Some("Gothic"));
// Characters the best face has keep it.
assert_eq!(fallback_face(&faces, &['a', 'b'], has).map(|f| f.family), Some("Mincho"));
// No face has them all: the best face stays, and names the missing glyph.
assert_eq!(fallback_face(&faces, &['a', 'ф'], |_, ch| ch.is_ascii()).map(|f| f.family), Some("Mincho"));
assert!(fallback_face(&[], &['a'], has).is_none());
}
}
+15 -5
View File
@@ -22,6 +22,7 @@ pub mod links;
pub mod ocr;
pub mod optimizer;
pub mod signature_image;
mod upright_image;
pub mod xfa;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
@@ -1505,7 +1506,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
ran.map_err(EditError::Invalid)?;
}
Edit::SetFieldImage { name, image } => {
let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?;
let (img, _) = embed_image(doc, name, image)?;
let px = match &*doc.get(img) {
pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32),
_ => (1, 1),
@@ -1562,7 +1563,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
ImageEdit::Flip { horizontal } => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal))
}
ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0),
ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
};
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
@@ -1602,7 +1603,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?;
}
Edit::AddImage { page, rect, name, bytes } => {
let (image, natural) = pdfcraft_create::image_xobject(doc, name, bytes)?;
let (image, natural) = embed_image(doc, name, bytes)?;
let rect = match rect {
Some(r) => *r,
None => {
@@ -1622,7 +1623,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into())));
};
let (image, _) = pdfcraft_create::image_xobject(doc, name, bytes)?;
let (image, _) = embed_image(doc, name, bytes)?;
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
}
Edit::ApplyRedactions { pages } => {
@@ -2977,6 +2978,15 @@ pub struct MarkFile {
pub page: usize,
}
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
fn embed_image(
doc: &mut pdfcraft_cos::Document,
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
let head = &f.bytes[..f.bytes.len().min(1024)];
@@ -2985,7 +2995,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraf
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
} else {
let (xobject, size) = pdfcraft_create::image_xobject(doc, &f.name, &f.bytes)?;
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
}
}
+47 -11
View File
@@ -4,6 +4,7 @@ use std::io::{Cursor, Read};
use std::sync::Arc;
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
use pdfcraft_render::PageInfo;
use crate::{Document, Edit, MarkFile, guard};
@@ -12,6 +13,8 @@ use crate::{Document, Edit, MarkFile, guard};
pub struct ImageSignaturePreview {
pub image: SignatureImage,
pub opacity: f32,
/// The page `/Rotate` the picture is drawn turned back by (see [`pdfcraft_annot::picture_rotation`]).
pub turn: i64,
page: usize,
bytes: Arc<Vec<u8>>,
config: pdfcraft_render::RenderConfig,
@@ -56,10 +59,11 @@ impl Document {
let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?;
let opacity =
pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?;
let mut cos = editor.cos.clone();
pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(Some(ImageSignaturePreview { image, opacity, page, bytes, config: self.config.clone() }))
Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() }))
})?
}
}
@@ -141,19 +145,29 @@ impl SignatureImage {
self.size
}
/// Left edge at `at`, vertically centered, with the same 150 pt width cap as typed names.
pub fn rect(&self, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
/// The user-space box for the image with its left edge at `at` (a user-space point on `page`),
/// vertically centered, with the same 150 pt width cap as typed names. Left and centered are
/// as displayed: on a page with `/Rotate` the box runs along the turned page's axes.
pub fn rect(&self, page: &PageInfo, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
if !at.iter().all(|v| v.is_finite()) {
return None;
}
let [w, h] = self.size;
let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64);
let (width, height) = (w as f64 * scale, h as f64 * scale);
Some([at[0], at[1] - height / 2.0, at[0] + width, at[1] + height / 2.0])
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(i64::from(page.rotation), [0.0; 4]);
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
Some([
at[0] + right[0].min(0.0) - up[0].abs(),
at[1] + right[1].min(0.0) - up[1].abs(),
at[0] + right[0].max(0.0) + up[0].abs(),
at[1] + right[1].max(0.0) + up[1].abs(),
])
}
pub fn edit(&self, page: usize, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
let rect = self.rect(at, initials)?;
pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
let rect = self.rect(info, at, initials)?;
let label = if initials { "Add initials" } else { "Add signature" };
Some(Edit::Batch {
label: label.into(),
@@ -172,6 +186,12 @@ impl SignatureImage {
mod tests {
use super::*;
/// A 300 x 400 page shown turned `rotation` degrees clockwise.
fn page(rotation: u16) -> PageInfo {
let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) };
PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation }
}
fn png(w: u32, h: u32) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 60, 80, 128]));
let mut out = Cursor::new(Vec::new());
@@ -184,10 +204,10 @@ mod tests {
let image = SignatureImage::from_bytes(&png(400, 40)).unwrap();
assert_eq!(image.size(), [400, 40]);
assert_eq!(&image.rgba()[..4], &[40, 60, 80, 128]);
let Edit::Batch { edits, .. } = image.edit(0, [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
let Edit::Batch { edits, .. } = image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
let Edit::AddCustomStamp { rect, .. } = &edits[0] else { panic!() };
assert_eq!(*rect, [20.0, 92.5, 170.0, 107.5]);
assert!(image.edit(0, [f64::NAN, 0.0], false, "").is_none());
assert!(image.edit(0, &page(0), [f64::NAN, 0.0], false, "").is_none());
assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image);
}
@@ -236,7 +256,7 @@ mod tests {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let a = &session.get(id).unwrap().info.annotations[0];
assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]);
}
@@ -247,7 +267,7 @@ mod tests {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let doc = session.get(id).unwrap();
let bytes = doc.bytes.clone();
let generation = doc.edit_generation();
@@ -268,7 +288,7 @@ mod tests {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let doc = session.doc_mut(id).unwrap();
let cos = &mut doc.editor.as_mut().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
@@ -278,4 +298,20 @@ mod tests {
let error = doc.image_signature_preview(0, 0).err().unwrap();
assert!(error.contains("4 megapixels"), "{error}");
}
/// "Left edge at the point, centered vertically" is as displayed: on a turned page the box
/// is another one in user space. The image is 64 x 32 pt; the displayed point is (60, 100).
#[test]
fn the_signature_box_is_laid_out_as_displayed_on_turned_pages() {
let image = SignatureImage::from_bytes(&png(80, 40)).unwrap();
for (rotation, at, want) in [
(0, [60.0, 300.0], [60.0, 284.0, 124.0, 316.0]),
(90, [100.0, 60.0], [84.0, 60.0, 116.0, 124.0]),
(180, [240.0, 100.0], [176.0, 84.0, 240.0, 116.0]),
(270, [200.0, 340.0], [184.0, 276.0, 216.0, 340.0]),
] {
let rect = image.rect(&page(rotation), at, false).unwrap();
assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rotation}: {rect:?}, want {want:?}");
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
//! (stamps, added images, replacements) go through here first.
use std::borrow::Cow;
use std::io::Cursor;
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
use image::metadata::Orientation;
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
/// Quality of a re-encoded JPEG (the camera's own is rarely higher).
const QUALITY: u8 = 92;
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
const MAX_PIXELS: u64 = 64 << 20;
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
/// everything else, and any JPEG this can't read, is returned unchanged.
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
match turned(bytes) {
Some(out) => Cow::Owned(out),
None => Cow::Borrowed(bytes),
}
}
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
let orientation = decoder.orientation().ok()?;
let (w, h) = decoder.dimensions();
if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let mut image = DynamicImage::from_decoder(decoder).ok()?;
image.apply_orientation(orientation);
let mut out = Vec::new();
{
let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY);
// Keep the resolution, which sets the image's natural size on the page.
if let Some(mut density) = jfif_density(bytes) {
if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) {
density.density = (density.density.1, density.density.0);
}
encoder.set_pixel_density(density);
}
match &image {
DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?,
_ => encoder.encode_image(&image.to_rgb8()).ok()?,
}
}
Some(out)
}
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
let mut i = 2usize;
while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) {
let len = usize::from(u16::from_be_bytes([hi, lo]));
let seg = bytes.get(i + 4..i + 2 + len)?;
if marker == 0xE0 && seg.starts_with(b"JFIF\0") {
let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None };
let unit = match unit {
1 => PixelDensityUnit::Inches,
2 => PixelDensityUnit::Centimeters,
_ => return None,
};
return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit });
}
if marker == 0xDA {
return None;
}
i += 2 + len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edit, MarkFile};
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
/// degrees clockwise).
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn orientation_is_applied_and_plain_images_are_left_alone() {
let photo = phone_jpeg(6, 144);
let turned = upright(&photo);
assert!(matches!(turned, Cow::Owned(_)));
let decoded = image::load_from_memory(&turned).unwrap();
assert_eq!((decoded.width(), decoded.height()), (20, 60));
assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches }));
let stored = phone_jpeg(1, 72);
assert!(matches!(upright(&stored), Cow::Borrowed(_)));
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
}
+27 -4
View File
@@ -96,10 +96,11 @@ fn level(b: &Block, body: f64) -> u8 {
/// How far two cell left edges may drift (points) and still be the same grid column.
const COL_TOL: f64 = 4.0;
/// More grid columns than this isn't a table. It also bounds a table's size: every row is
/// materialized to all its columns, so a hostile page laid out as a staircase of blocks would
/// otherwise make (blocks / 2)² cells.
const MAX_COLS: usize = 64;
/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or
/// more columns doesn't open. It also bounds a table's size: every row is materialized to all
/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make
/// (blocks / 2)² cells.
const MAX_COLS: usize = 63;
/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells.
fn is_cell_like(b: &Block) -> bool {
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
@@ -809,6 +810,28 @@ mod tests {
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
}
#[test]
fn word_opens_tables_up_to_its_column_limit() {
// Rows of short numbers on a regular grid, n columns wide.
let grid = |n: usize| Page {
width: 1500.0,
height: 792.0,
blocks: (0..3)
.flat_map(|row| {
(0..n).map(move |col| {
let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0);
Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false }
})
})
.collect(),
images: Vec::new(),
};
let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches("<w:gridCol ").count();
assert_eq!(grid_cols(&grid(63)), 63, "63 columns is still a table");
// Word refuses to open a document with a 64-column table, so that grid stays text.
assert_eq!(grid_cols(&grid(64)), 0);
}
#[test]
fn two_column_body_text_is_not_mistaken_for_a_table() {
let para = |t: &str, x: f64, y: f64| Block {
+3
View File
@@ -33,6 +33,9 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
keep Mincho. Type 3 paths and advances both come from that selected face. Missing weights
fall back to a real Regular face, never synthetic bold; no italic face is supplied. The
default `document_japanese_font` / `japanese_glyph` APIs still use regular document Mincho.
- `document_japanese_fonts_for_style`: every `Jpan` face in that order of preference, so the
editor can use the first one that has all of the replacement's glyphs (the faces differ in
coverage, e.g. of Cyrillic).
- `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`.
Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect
+29 -10
View File
@@ -96,22 +96,36 @@ pub fn document_japanese_font_for_style(serif: bool, bold: bool) -> Option<&'sta
document_face(CRAFT_FONTS, serif, bold)
}
/// Every `Jpan` face that can stand in for the document text, the best match for the style first
/// (the face [`document_japanese_font_for_style`] returns), then the others. Not every face has
/// every glyph (their coverage of e.g. Cyrillic differs), so a caller can move on to the next one.
/// Empty without craft-fonts.
pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'static CraftFont> {
document_faces(CRAFT_FONTS, serif, bold)
}
fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> {
document_faces(faces, serif, bold).into_iter().next()
}
fn document_faces(faces: &[CraftFont], serif: bool, bold: bool) -> Vec<&CraftFont> {
let jpan = || faces.iter().filter(|f| f.covers("Jpan"));
let mut preferred = Vec::new();
if !serif {
let style = if bold { "Bold" } else { "Regular" };
if let Some(face) = jpan()
.find(|f| f.family == "BIZ UDPGothic" && f.style == style)
.or_else(|| jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular"))
{
return Some(face);
preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == style));
preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular"));
}
for family in ["Shippori Mincho", "BIZ UDMincho"] {
preferred.extend(jpan().find(|f| f.family == family && f.style == "Regular"));
}
let mut out: Vec<&CraftFont> = Vec::new();
for face in preferred.into_iter().chain(jpan().filter(|f| f.style == "Regular")).chain(jpan()) {
if !out.iter().any(|f| std::ptr::eq(*f, face)) {
out.push(face);
}
}
["Shippori Mincho", "BIZ UDMincho"]
.iter()
.find_map(|family| jpan().find(|f| f.family == *family && f.style == "Regular"))
.or_else(|| jpan().find(|f| f.style == "Regular"))
.or_else(|| jpan().next())
out
}
const fn str_eq(a: &str, b: &str) -> bool {
@@ -191,6 +205,11 @@ mod tests {
assert_eq!(document_face(&faces, true, true).unwrap().bytes, b"serif");
assert_eq!(document_face(&faces[..2], false, true).unwrap().bytes, b"sans");
assert_eq!(document_face(&faces[..1], false, true).unwrap().bytes, b"serif");
// Every Japanese face is a candidate, the style's match first and none twice.
let order: Vec<&[u8]> = document_faces(&faces, false, true).iter().map(|f| f.bytes).collect();
assert_eq!(order, [b"bold".as_slice(), b"sans", b"serif"]);
let order: Vec<&[u8]> = document_faces(&faces, true, false).iter().map(|f| f.bytes).collect();
assert_eq!(order, [b"serif".as_slice(), b"sans", b"bold"]);
assert!(document_face(&faces[3..], false, true).is_none());
assert!(document_face(&[], false, true).is_none());
}
+2 -2
View File
@@ -10,8 +10,8 @@ mod encodings;
pub mod pdf;
mod script;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, document_japanese_font_for_style, ui_arabic_fonts, ui_chinese_fonts,
ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts,
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style,
ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts,
};
pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline};
+272 -65
View File
@@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option<Dict> {
o.and_then(|o| doc.resolve(o).as_dict().cloned())
}
fn hex_bytes(tok: &str) -> Option<Vec<u8>> {
let t = tok.trim_start_matches('<').trim_end_matches('>');
if !t.len().is_multiple_of(2) || t.is_empty() {
return None;
/// Most entries a CMap may add, so a small stream of overlapping `bfrange`s can't take minutes
/// or gigabytes; a full 2-byte code space is 65 536. Entries past it are ignored.
const MAX_CMAP_ENTRIES: usize = 1 << 20;
/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few
/// thousand.
const MAX_CMAP_RANGES: usize = 1 << 16;
/// A token of a CMap stream (PostScript syntax, ISO 32000-2 §9.10.3).
#[derive(Debug, PartialEq)]
enum Tok<'a> {
/// A `<…>` string; `None` when it isn't an even number of hex digits.
Hex(Option<Vec<u8>>),
Open,
Close,
/// An operator, number or name (with its `/`), a `(…)` string or a `<<`/`>>`.
Word(&'a [u8]),
}
/// Splits a CMap into tokens without assuming whitespace between them (`<01><0041>` is two
/// strings), skipping `%` comments.
struct Lexer<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Lexer<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn at(&self, i: usize) -> Option<u8> {
self.data.get(i).copied()
}
}
fn is_cmap_space(b: u8) -> bool {
matches!(b, 0 | b'\t' | b'\n' | 0x0c | b'\r' | b' ')
}
fn is_cmap_delimiter(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
impl<'a> Iterator for Lexer<'a> {
type Item = Tok<'a>;
fn next(&mut self) -> Option<Tok<'a>> {
loop {
let b = self.at(self.pos)?;
if is_cmap_space(b) {
self.pos += 1;
} else if b == b'%' {
while self.at(self.pos).is_some_and(|c| c != b'\n' && c != b'\r') {
self.pos += 1;
}
} else {
break;
}
}
let start = self.pos;
let b = self.at(start)?;
self.pos += 1;
match b {
b'[' => Some(Tok::Open),
b']' => Some(Tok::Close),
b'<' | b'>' if self.at(self.pos) == Some(b) => {
self.pos += 1;
Some(Tok::Word(self.data.get(start..self.pos)?))
}
b'<' => {
let mut digits = Vec::new();
let mut valid = true;
loop {
match self.at(self.pos) {
// Unterminated: what's left is one bad string.
None => return Some(Tok::Hex(None)),
Some(b'>') => break,
Some(c) if is_cmap_space(c) => {}
Some(c) => match (c as char).to_digit(16) {
// A hex digit is below 16, so it fits a u8.
Some(d) => digits.push(d as u8),
None => valid = false,
},
}
self.pos += 1;
}
self.pos += 1;
if !valid || digits.is_empty() || !digits.len().is_multiple_of(2) {
return Some(Tok::Hex(None));
}
// `chunks` never yields an empty slice, so `p[0]` exists.
Some(Tok::Hex(Some(digits.chunks(2).map(|p| p[0] << 4 | p.get(1).copied().unwrap_or(0)).collect())))
}
b'(' => {
// Literal strings nest and escape their parentheses; only skipping them matters.
let mut depth = 1usize;
while depth > 0 {
match self.at(self.pos) {
None => break,
Some(b'\\') => self.pos += 1,
Some(b'(') => depth += 1,
Some(b')') => depth -= 1,
_ => {}
}
self.pos += 1;
}
Some(Tok::Word(self.data.get(start..self.pos.min(self.data.len()))?))
}
_ => {
// A name keeps its `/`; other delimiters (`)`, `>`, `{`, `}`) stand alone.
if b == b'/' || !is_cmap_delimiter(b) {
while self.at(self.pos).is_some_and(|c| !is_cmap_space(c) && !is_cmap_delimiter(c)) {
self.pos += 1;
}
}
Some(Tok::Word(self.data.get(start..self.pos)?))
}
}
}
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect()
}
fn be(b: &[u8]) -> u32 {
@@ -89,47 +202,64 @@ fn style_from_name(name: &str) -> (bool, bool) {
)
}
/// Codespace ranges and CID mappings from an embedded CMap stream.
/// The next entry of a `begin… end…` section: `N` tokens, or `None` at its `end` operator or
/// the end of the stream.
fn entry<'a, const N: usize>(toks: &mut Lexer<'a>, end: &[u8]) -> Option<[Tok<'a>; N]> {
let mut out = Vec::with_capacity(N);
for _ in 0..N {
match toks.next()? {
Tok::Word(w) if w == end => return None,
t => out.push(t),
}
}
out.try_into().ok()
}
fn number(t: &Tok) -> Option<u32> {
match t {
Tok::Word(w) => std::str::from_utf8(w).ok()?.parse().ok(),
_ => None,
}
}
/// Codespace ranges and CID mappings from an embedded CMap stream, at most
/// [`MAX_CMAP_RANGES`] of each (every glyph searches them).
#[allow(clippy::type_complexity)]
fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec<u8>, Vec<u8>)>, Vec<(u32, u32, u32)>) {
let text = String::from_utf8_lossy(data);
let toks: Vec<&str> = text.split_whitespace().collect();
let (mut spaces, mut cids) = (Vec::new(), Vec::new());
let mut i = 0;
while i < toks.len() {
match toks[i] {
"begincodespacerange" => {
i += 1;
while i + 1 < toks.len() && toks[i] != "endcodespacerange" {
if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]))
let mut toks = Lexer::new(data);
while let Some(t) = toks.next() {
match t {
Tok::Word(b"begincodespacerange") => {
while let Some([lo, hi]) = entry(&mut toks, b"endcodespacerange") {
if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi)
&& lo.len() == hi.len()
&& spaces.len() < MAX_CMAP_RANGES
{
spaces.push((lo.len(), lo, hi));
}
i += 2;
}
}
"begincidrange" => {
i += 1;
while i + 2 < toks.len() && toks[i] != "endcidrange" {
if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::<u32>()) {
cids.push((be(&lo), be(&hi), c));
Tok::Word(b"begincidrange") => {
while let Some([lo, hi, c]) = entry(&mut toks, b"endcidrange") {
if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi)), Some(c)) = (&lo, &hi, number(&c))
&& cids.len() < MAX_CMAP_RANGES
{
cids.push((be(lo), be(hi), c));
}
i += 3;
}
}
"begincidchar" => {
i += 1;
while i + 1 < toks.len() && toks[i] != "endcidchar" {
if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::<u32>()) {
cids.push((be(&code), be(&code), c));
Tok::Word(b"begincidchar") => {
while let Some([code, c]) = entry(&mut toks, b"endcidchar") {
if let (Tok::Hex(Some(code)), Some(c)) = (&code, number(&c))
&& cids.len() < MAX_CMAP_RANGES
{
cids.push((be(code), be(code), c));
}
i += 2;
}
}
_ => {}
}
i += 1;
}
spaces.sort_by_key(|s| s.0);
(spaces, cids)
@@ -312,7 +442,7 @@ impl Metrics {
if self.cid_map.is_empty() {
return code;
}
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo))
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo))
}
/// The advance of `code` in text space per unit of font size.
@@ -392,60 +522,66 @@ fn utf16(b: &[u8]) -> String {
String::from_utf16_lossy(&units)
}
/// `bfchar`/`bfrange` entries of a ToUnicode CMap.
/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] of them.
fn parse_to_unicode(data: &[u8], out: &mut HashMap<u32, String>) {
let text = String::from_utf8_lossy(data);
// Tokenise, keeping arrays' brackets as tokens.
let spaced = text.replace('[', " [ ").replace(']', " ] ");
let toks: Vec<&str> = spaced.split_whitespace().collect();
let mut i = 0;
while i < toks.len() {
match toks[i] {
"beginbfchar" => {
i += 1;
while i + 1 < toks.len() && toks[i] != "endbfchar" {
if let (Some(c), Some(u)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) {
let mut budget = MAX_CMAP_ENTRIES;
let mut toks = Lexer::new(data);
while let Some(t) = toks.next() {
match t {
Tok::Word(b"beginbfchar") => {
while let Some([c, u]) = entry(&mut toks, b"endbfchar") {
if let (Tok::Hex(Some(c)), Tok::Hex(Some(u))) = (c, u) {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(be(&c), utf16(&u));
}
i += 2;
}
}
"beginbfrange" => {
i += 1;
while i + 2 < toks.len() && toks[i] != "endbfrange" {
let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) else { break };
Tok::Word(b"beginbfrange") => {
while let Some([lo, hi, dst]) = entry(&mut toks, b"endbfrange") {
let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) else { break };
let (lo, hi) = (be(&lo), be(&hi));
if toks[i + 2] == "[" {
let mut j = i + 3;
let mut code = lo;
while j < toks.len() && toks[j] != "]" {
if let Some(u) = hex_bytes(toks[j]) {
out.insert(code, utf16(&u));
match dst {
// One destination per code, from `lo` up to `hi`.
Tok::Open => {
let mut code = (hi >= lo).then_some(lo);
for t in toks.by_ref() {
let Some(c) = code else {
if t == Tok::Close {
break;
}
continue;
};
match t {
Tok::Close => break,
Tok::Hex(Some(u)) => {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(c, utf16(&u));
}
// A bad entry still stands for its code.
_ => {}
}
code = c.checked_add(1).filter(|c| *c <= hi);
}
code += 1;
j += 1;
}
i = j + 1;
} else {
if let Some(u) = hex_bytes(toks[i + 2])
&& hi >= lo
&& hi - lo < 65536
{
Tok::Hex(Some(u)) if hi >= lo && hi - lo < 65536 => {
let mut units: Vec<u16> = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect();
for code in lo..=hi {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(code, String::from_utf16_lossy(&units));
if let Some(last) = units.last_mut() {
*last = last.wrapping_add(1);
}
}
}
i += 3;
_ => {}
}
}
}
_ => {}
}
i += 1;
}
}
@@ -484,13 +620,14 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap<u32, Str
let mut code = 0u32;
for item in diffs {
match &item {
Object::Int(n) => code = (*n).max(0) as u32,
// Out-of-range codes map nothing a simple font can show.
Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX),
Object::Name(n) => {
match glyph_unicode(&String::from_utf8_lossy(n)) {
Some(c) => out.insert(code, c.to_string()),
None => out.remove(&code),
};
code += 1;
code = code.saturating_add(1);
}
_ => {}
}
@@ -637,4 +774,74 @@ mod tests {
assert_eq!(glyph_unicode("u1F600"), Some('😀'));
assert_eq!(glyph_unicode("nonsense"), None);
}
fn to_unicode(cmap: &[u8]) -> HashMap<u32, String> {
let mut out = HashMap::new();
parse_to_unicode(cmap, &mut out);
out
}
#[test]
fn cmap_tokens_need_no_space_between_them() {
// Hex strings, brackets and operators may touch; comments and literal strings are skipped.
let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\
2 beginbfchar<0003><0020><0010><00 E9>endbfchar \
1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange";
let map = to_unicode(cmap);
let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect();
got.sort_unstable();
assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]);
let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00><FF>endcodespacerange 1 begincidrange<20><7E>1 endcidrange");
assert_eq!(spaces, [(1, vec![0], vec![0xff])]);
assert_eq!(cids, [(0x20, 0x7e, 1)]);
}
#[test]
fn malformed_cmaps_never_panic() {
// Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked.
for cmap in [
"1 beginbfchar <0é0> <0041> <AéB> <0042> endbfchar".as_bytes(),
"1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(),
b"1 beginbfchar <0001> <004",
b"1 beginbfchar <123> <0041> <0002> <zz> <0003> endbfchar",
b"1 beginbfrange <FFFF> <0001> [<0041>] endbfrange",
b"1 beginbfrange <0001> <0002> [<0041> (",
b"1 beginbfrange <0001>",
b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %",
] {
to_unicode(cmap);
parse_cmap(cmap);
}
// Code counters at the top of the range stop instead of overflowing.
let map = to_unicode(b"1 beginbfrange <FFFFFFFE> <FFFFFFFF> [<0041> <0042> <0043>] endbfrange");
assert_eq!(map.len(), 2);
assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B"));
// A malformed array entry keeps its place, so the next one maps the next code.
let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <zz> <0043>] endbfrange");
assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C")));
let mut m = Metrics::fallback();
(_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange");
assert_eq!(m.cid(5), u32::MAX);
let mut doc = Document::new_empty();
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")]));
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]);
assert_eq!(unicode_map(&doc, &f, false).get(&u32::MAX).map(String::as_str), Some("B"));
}
#[test]
fn a_cmap_adds_at_most_max_entries() {
// 200 ranges of 65 536 codes would be 13 million strings from 4 KB of stream.
let mut cmap = b"200 beginbfrange ".to_vec();
for k in 0..200u32 {
cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes());
}
cmap.extend(b"endbfrange");
assert_eq!(to_unicode(&cmap).len(), MAX_CMAP_ENTRIES);
let mut many = b"begincidrange ".to_vec();
for k in 0..MAX_CMAP_RANGES + 10 {
many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes());
}
assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES);
}
}
+2
View File
@@ -8,6 +8,8 @@ L3/L4 crates need:
- `Page::crop`, `Page::rotation` and `Page::view_matrix`: the displayed page's geometry, and the
matrix from "display space" (origin at the bottom-left of the page as shown, after `/Rotate`)
to user space, so content can be placed the way the reader sees the page.
`view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a
comment's picture upright inside its user-space `/Rect` on a turned page.
`organize`, `annot` and `forms` still carry their own small walkers; they move here as the model
grows (ADR-0004).
+15 -7
View File
@@ -49,13 +49,21 @@ impl Page {
/// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as
/// shown, y up, after `/Rotate`) to user space.
pub fn view_matrix(&self, doc: &Document) -> [f64; 6] {
let [x0, y0, x1, y1] = self.crop(doc);
match self.rotation(doc) {
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
}
view_matrix_for(self.rotation(doc), self.crop(doc))
}
}
/// [`Page::view_matrix`] for a region `crop` (`[x0, y0, x1, y1]`) shown turned clockwise by
/// `rotation` (0, 90, 180 or 270): the matrix from the turned region's own display space
/// (origin at its bottom-left, y up) to the region's space. For a rectangle in user space it
/// puts an upright picture into that rectangle as the page displays it.
pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
let [x0, y0, x1, y1] = crop;
match rotation {
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
}
}
+6
View File
@@ -16,7 +16,13 @@ lopdf = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# Moves rendered pixels out of the renderer's pixmap without copying; already in the tree through hayro.
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
# lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses.
skrifa = "0.44"
+108 -14
View File
@@ -304,30 +304,35 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
}
let options = load_options(password);
let structure = catch_unwind(AssertUnwindSafe(|| match Document::load_mem_with_options(&bytes, options) {
Ok(doc) => {
let mut tmp = DocInfo::default();
std::mem::swap(&mut tmp.pages, &mut info.pages);
Inspector::new(&doc).fill(&mut tmp);
Ok(tmp)
}
Err(e) => Err(e.to_string()),
inspect_structure(&mut info, |tmp| {
let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?;
Inspector::new(&doc).fill(tmp);
Ok(())
});
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
let structure = catch_unwind(AssertUnwindSafe(|| {
let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() };
fill(&mut tmp)?;
Ok::<_, String>(tmp)
}));
match structure {
Ok(Ok(mut filled)) => {
filled.file_size = info.file_size;
filled.pdf_version = std::mem::take(&mut info.pdf_version);
info = filled;
*info = filled;
}
Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")),
Err(p) => {
info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p)));
}
}
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
struct Inspector<'a> {
@@ -955,7 +960,8 @@ fn alpha(n: usize) -> String {
/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged.
pub fn pretty_date(s: &str) -> String {
let d = s.trim_start_matches("D:");
if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) {
if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) {
// Twelve ASCII digits make every slice below a UTF-8 character boundary.
format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12])
} else {
s.to_string()
@@ -1106,6 +1112,94 @@ trailer << /Root 1 0 R >>
);
}
#[test]
fn structure_failure_preserves_renderer_pages() {
for crash in [true, false] {
let mut info = DocInfo {
file_size: 321,
pdf_version: "1.7".into(),
pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }],
..Default::default()
};
inspect_structure(&mut info, |tmp| {
tmp.pages.clear();
tmp.title = Some("partially inspected".into());
if crash {
panic!("synthetic structure inspection failure");
}
Err("synthetic structure inspection failure".into())
});
assert_eq!(info.pages.len(), 1);
let page = &info.pages[0];
assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90));
assert_eq!(info.file_size, 321);
assert_eq!(info.pdf_version, "1.7");
assert!(info.title.is_none());
assert_eq!(info.warnings.len(), 1);
assert!(info.warnings[0].contains("synthetic structure inspection failure"));
assert_eq!(info.warnings[0].contains("crashed"), crash);
}
}
#[test]
fn invalid_dates_with_unicode_are_unchanged() {
for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] {
assert_eq!(pretty_date(input), input);
}
// Cover every position before the 12-byte prefix, including characters that
// straddle its end. None of these strings is an ASCII PDF date.
for character in ['é', '€', '😀'] {
for prefix_len in 0..12 {
let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len));
assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits");
}
}
}
#[test]
fn unicode_annotation_date_does_not_prevent_opening() {
use lopdf::dictionary;
let date = "D:12345678901éX";
let mut encoded_date = vec![0xFE, 0xFF];
encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes));
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let annot_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()],
"M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal),
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Annots" => vec![annot_id.into()],
});
doc.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}
.into(),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("write synthetic fixture");
let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string");
assert_eq!(info.pages.len(), 1);
assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0));
assert_eq!(info.annotations.len(), 1);
assert_eq!(info.annotations[0].modified.as_deref(), Some(date));
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");
+202 -4
View File
@@ -17,6 +17,7 @@ use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_interpret::font::{FontData, FontQuery};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::Pixmap;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings, render};
@@ -73,7 +74,13 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
return None;
}
let face = match japanese_face(f.post_script_name.as_deref().unwrap_or_default(), f.is_serif, f.is_bold || f.font_weight >= 600) {
JapaneseFace::Mincho => pdfcraft_fonts::document_japanese_font(),
// BIZ UDMincho before the document face (Shippori Mincho): it covers half-width katakana
// (U+FF61–U+FF9F), which Shippori Mincho lacks, and hayro draws a CID the substitute
// can't map by Unicode by glyph index, i.e. as an unrelated glyph (fi, fl, …).
JapaneseFace::Mincho => pdfcraft_fonts::ui_japanese_fonts()
.into_iter()
.find(|c| c.family == "BIZ UDMincho" && c.style == "Regular")
.or_else(pdfcraft_fonts::document_japanese_font),
JapaneseFace::Gothic { bold } => {
let faces = pdfcraft_fonts::ui_japanese_fonts();
let style = if bold { "Bold" } else { "Regular" };
@@ -216,7 +223,7 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
}
};
let pixmap = render(page, cache, settings, &rs);
Ok((pixmap.width() as u32, pixmap.height() as u32, pixmap.data_as_u8_slice().to_vec(), None))
Ok((pixmap.width() as u32, pixmap.height() as u32, into_bytes(pixmap), None))
}));
match result {
Ok(Ok(v)) => Ok(v),
@@ -225,6 +232,13 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
}
}
/// The pixmap's premultiplied RGBA bytes, moved out rather than copied (a page raster can be
/// hundreds of megabytes). Four one-byte channels always recast to bytes in place; the copy is
/// only there because the cast's signature allows failure.
fn into_bytes(pixmap: Pixmap) -> Vec<u8> {
bytemuck::allocation::try_cast_vec(pixmap.take()).unwrap_or_else(|(_, pixels)| pixels.iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect())
}
fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)>) -> RenderedPage {
let millis = start.millis();
match r {
@@ -292,6 +306,9 @@ struct Shared {
/// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a
/// pathological page cannot trap every worker in turn.
stuck: Mutex<std::collections::HashSet<(usize, RequestKind)>>,
/// Set when the pool is dropped: the workers' renders stop at their next content operator,
/// since nobody can receive their answers any more.
dropped: Arc<std::sync::atomic::AtomicBool>,
/// Test hook: make one page slow.
#[cfg(test)]
slow_page: Mutex<Option<(usize, std::time::Duration)>>,
@@ -403,7 +420,9 @@ impl RenderPool {
let mut pool = Self::new(bytes.clone(), 0, config.clone());
if pool.inline.is_none() {
// Native `new` always spawns at least one worker; drop to inline explicitly.
pool = Self { shared: Arc::default(), wake: Mutex::new(Vec::new()), _workers: Mutex::new(Vec::new()), ..pool };
pool.shared = Arc::default();
pool.wake = Mutex::new(Vec::new());
pool._workers = Mutex::new(Vec::new());
pool.inline = Some(std::cell::RefCell::new(PageRenderer::new(bytes, config)));
}
pool
@@ -489,9 +508,21 @@ impl RenderPool {
}
}
impl Drop for RenderPool {
fn drop(&mut self) {
self.shared.dropped.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
/// A worker's interpreter settings: the document's, stopping once the pool is dropped.
#[cfg(not(target_arch = "wasm32"))]
fn worker_settings(config: &RenderConfig, shared: &Shared) -> InterpreterSettings {
InterpreterSettings { cancelled: Some(shared.dropped.clone()), ..config.settings() }
}
#[cfg(not(target_arch = "wasm32"))]
fn worker(id: usize, bytes: Arc<Vec<u8>>, config: RenderConfig, shared: Arc<Shared>, wake: Receiver<()>, out: Sender<RenderedPage>) {
let settings = config.settings();
let settings = worker_settings(&config, &shared);
// Outer loop: (re)build parser + cache; rebuilt after a renderer panic.
loop {
let pdf = parse(&bytes, config.password.as_deref());
@@ -709,6 +740,18 @@ trailer << /Root 1 0 R >>
assert_eq!(px(80, 5), &[255, 255, 255, 255]);
}
#[test]
fn rendered_pixels_are_moved_out_of_the_pixmap() {
let mut pixmap = Pixmap::new(3, 2);
for (i, b) in pixmap.data_as_u8_slice_mut().iter_mut().enumerate() {
*b = i as u8;
}
let (at, expected) = (pixmap.data_as_u8_slice().as_ptr(), pixmap.data_as_u8_slice().to_vec());
let bytes = into_bytes(pixmap);
assert_eq!(bytes, expected);
assert_eq!(bytes.as_ptr(), at, "the pixmap's own buffer, not a copy");
}
#[test]
fn missing_page_and_garbage_input_fail_gracefully() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
@@ -1098,6 +1141,38 @@ trailer << /Root 1 0 R >>
assert!(pool.try_recv().is_none());
}
#[test]
fn dropping_the_pool_cancels_its_renders() {
const TEXT: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 35 >> stream
BT /F1 12 Tf 20 70 Td (Hello) Tj ET
endstream endobj
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
let settings = worker_settings(&pool.config, &pool.shared);
let flag = settings.cancelled.clone().expect("workers can be cancelled");
assert!(Arc::ptr_eq(&flag, &pool.shared.dropped));
let shapes = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let text = Pdf::new(Arc::new(TEXT.to_vec())).expect("parses");
let pages = shapes.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { bg_color: WHITE, ..Default::default() };
let blue = |s: &InterpreterSettings| {
let p = render(page, &RenderCache::new(), s, &rs).sample(20, 30);
[p.r, p.g, p.b, p.a] == [0, 0, 255, 255]
};
let glyphs = |s: &InterpreterSettings| crate::text::extract_page(&text, 0, s).map_or(0, |t| t.glyphs.len());
assert!(blue(&settings) && glyphs(&settings) == 5, "a live pool renders and reads everything");
drop(pool);
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
assert!(!blue(&settings) && glyphs(&settings) == 0, "once the pool is gone, interpretation stops before drawing");
}
#[test]
fn extracts_text_with_positions() {
let pdf = b"%PDF-1.7
@@ -1334,6 +1409,106 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap");
}
/// Render a 40 × 40 pt page at 1:1 on its own thread, with `/GS1` (from `gs1`) in its
/// resources, and fail instead of waiting when the renderer stalls.
fn render_with_gs1(content: &str, gs1: &str) -> RenderedPage {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
page
}
/// The pixel at device (x, y), y down, of a 40-pixel-wide page.
fn px40(page: &RenderedPage, x: usize, y: usize) -> &[u8] {
&page.rgba[((y * 40 + x) * 4)..][..4]
}
/// From `cargo xtask fuzz`: an ExtGState `/D [[11] 0]` on a line that starts at x =
/// 92234775807 cut it into billions of dashes, and stroke expansion allocated more than
/// 5 GB. A tiny dash on an ordinary line does the same (`[0.000001] 0 d` on 20 pt passed
/// 10 GB), and so does a long pattern on many short subpaths, because every subpath restarts
/// it. Vendored hayro patch: a stroke whose dash pattern could cut it into more than
/// `MAX_DASHES_PER_STROKE` pieces is drawn solid.
#[test]
fn dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render() {
let red = [255, 0, 0, 255];
// The fuzzed shape. A line that far out isn't drawn even undashed (its coordinates are
// beyond the rasterizer's range), so this case only proves the page finishes and the
// content after the stroke is drawn.
let page = render_with_gs1("1 0 0 RG 4 w /GS1 gs 92234775807 20 m 30 20 l S 0 0 1 rg 0 0 4 4 re f", "<< /D [[11] 0] >>");
assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "the square after the stroke is drawn");
for (what, content, gs1) in [
("a tiny dash on an ordinary line", "1 0 0 RG 4 w [0.000001] 0 d 10 20 m 30 20 l S", "<< >>"),
("a tiny dash from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[0.000001] 0] >>"),
(
"a tiny dash under a shrinking matrix",
"1 0 0 RG 0.0000001 0 0 0.0000001 0 0 cm 40000000 w [1] 0 d 100000000 200000000 m 300000000 200000000 l S",
"<< >>",
),
] {
let page = render_with_gs1(content, gs1);
// Device row 20 is user y 20; the stroke covers x 10 to 30.
for x in [12, 20, 28] {
assert_eq!(px40(&page, x, 20), &red, "{what}: ({x}, 20) is drawn, solid");
}
assert_eq!(px40(&page, 35, 20), &[255, 255, 255, 255], "{what}: beyond the end of the line");
}
// A long pattern on many short subpaths: 9,999 tiny dashes, then a long one, restarted on
// each of 10,000 subpaths 0.01 pt long, is 100 million pieces from a 300 KB stream.
let mut many = String::from("1 0 0 RG 4 w [");
many.push_str(&"0.000001 ".repeat(9_999));
many.push_str("1000] 0 d ");
many.push_str(&"10 20 m 10.01 20 l ".repeat(10_000));
many.push_str("S 0 0 1 rg 0 0 4 4 re f");
let page = render_with_gs1(&many, "<< >>");
assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "a pattern restarted on many subpaths finishes");
// A fine pattern below the cap still dashes: 200,000 pieces of 0.0001 pt cover about
// half of each pixel, so the line is neither solid red nor missing.
let page = render_with_gs1("1 0 0 RG 4 w [0.0001] 0 d 10 20 m 30 20 l S", "<< >>");
let p = px40(&page, 20, 20);
assert!(p[0] == 255 && (40..=215).contains(&p[1]), "a fine dash below the cap is drawn dashed: {p:?}");
// An ordinary dash pattern still dashes: butt-capped 4 pt dashes from x 0.
let page = render_with_gs1("1 0 0 RG 4 w [4 4] 0 d 0 20 m 40 20 l S", "<< >>");
assert_eq!(px40(&page, 2, 20), &red, "inside the first dash");
assert_eq!(px40(&page, 6, 20), &[255, 255, 255, 255], "inside the first gap");
assert_eq!(px40(&page, 10, 20), &red, "inside the second dash");
}
/// From `cargo xtask fuzz` triage: a dash array whose entries sum to less than zero made
/// kurbo's search for the starting dash loop for ever (`[-1 -1] 0 d` never finished).
/// ISO 32000-2 §8.4.3.6 requires nonnegative entries. Vendored hayro patch: a pattern with a
/// negative entry, or a period that isn't positive, is drawn solid.
#[test]
fn invalid_dash_patterns_terminate_and_are_drawn_solid() {
for (what, content, gs1) in [
("[-1 -1]", "1 0 0 RG 4 w [-1 -1] 0 d 10 20 m 30 20 l S", "<< >>"),
("[-1] with a phase", "1 0 0 RG 4 w [-1] 5 d 10 20 m 30 20 l S", "<< >>"),
("[-2 -3] from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[-2 -3] 0] >>"),
("[-1 3], a negative entry in a positive period", "1 0 0 RG 4 w [-1 3] 0 d 10 20 m 30 20 l S", "<< >>"),
] {
let page = render_with_gs1(content, gs1);
for x in [12, 20, 28] {
assert_eq!(px40(&page, x, 20), &[255, 0, 0, 255], "{what}: ({x}, 20) is drawn, solid");
}
}
}
/// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own
/// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen
/// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs,
@@ -1652,6 +1827,29 @@ trailer << /Root 1 0 R >>
}
}
/// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font
/// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them
/// (Shippori Mincho) draws each CID by glyph index instead, as unrelated glyphs (fi, fl).
#[test]
fn mincho_substitute_covers_half_width_katakana() {
use hayro::hayro_interpret::font::FallbackFontQuery;
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
use skrifa::MetadataProvider;
let query = FontQuery::Fallback(FallbackFontQuery {
post_script_name: Some("HeiseiMin-W3".into()),
character_collection: Some(CharacterCollection { family: CidFamily::AdobeJapan1, supplement: 2 }),
..FallbackFontQuery::default()
});
let Some((data, index)) = super::japanese_fallback(&query) else {
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check");
return;
};
let font = skrifa::FontRef::from_index((*data).as_ref(), index).expect("a craft-fonts face parses");
let charmap = font.charmap();
let missing: Vec<char> = ('\u{FF61}'..='\u{FF9F}').chain(['日', '本']).filter(|&c| charmap.map(c).is_none()).collect();
assert!(missing.is_empty(), "the Mincho substitute lacks {missing:?}");
}
#[test]
fn japanese_font_names_pick_mincho_or_gothic() {
use super::JapaneseFace::{Gothic, Mincho};
+153 -16
View File
@@ -1,7 +1,14 @@
//! A small DER reader and writer (ITU-T X.690): just what X.509, CMS and PKCS#12 need.
//! A small BER/DER reader and writer (ITU-T X.690): just what X.509, CMS and PKCS#12 need.
//!
//! [`Tlv`] borrows from the input and keeps its full encoding (`raw`), so signed structures can
//! be verified over the exact bytes that were signed.
//!
//! Reading is strict DER by default, which is what everything signed must use. [`Tlv::parse_ber`]
//! also accepts the two BER constructions a reader meets in practice — the indefinite length form
//! and strings split into segments — which PKCS #12 needs: Windows writes `.p12` files that way.
//! Writing is always DER.
use std::borrow::Cow;
use crate::SignError;
@@ -11,6 +18,8 @@ pub mod tag {
pub const INTEGER: u8 = 0x02;
pub const BIT_STRING: u8 = 0x03;
pub const OCTET_STRING: u8 = 0x04;
/// An OCTET STRING split into segments (BER only, X.690 §8.21); see [`super::Tlv::octets`].
pub const OCTET_STRING_SEGMENTS: u8 = 0x24;
pub const NULL: u8 = 0x05;
pub const OID: u8 = 0x06;
pub const UTF8_STRING: u8 = 0x0C;
@@ -36,56 +45,107 @@ fn bad(what: &str) -> SignError {
SignError::Malformed(what.to_string())
}
/// One DER element.
/// Which encoding rules a reader accepts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Form {
/// DER: every length definite, every string primitive. Anything whose bytes are signed is read
/// this way, so that what is verified is exactly what was written.
Der,
/// BER as well: the indefinite length form (X.690 §8.1.3.6) and strings split into segments
/// (§8.21).
Ber,
}
/// One BER/DER element.
#[derive(Clone, Copy, Debug, Eq)]
pub struct Tlv<'a> {
pub tag: u8,
/// The contents octets.
/// The contents octets (without the end-of-contents octets of an indefinite length).
pub value: &'a [u8],
/// The whole encoding (identifier, length and contents).
/// The whole encoding (identifier, length, contents and any end-of-contents octets).
pub raw: &'a [u8],
/// The rules this element was read with. Its children are read the same way.
form: Form,
}
/// Two elements are equal when they encode the same thing; the rules they were read with are not
/// part of that.
impl PartialEq for Tlv<'_> {
fn eq(&self, other: &Self) -> bool {
self.tag == other.tag && self.value == other.value && self.raw == other.raw
}
}
impl<'a> Tlv<'a> {
/// Parse one element at the start of `input`; returns it and the rest.
/// Parse one DER element at the start of `input`; returns it and the rest.
pub fn parse(input: &'a [u8]) -> Result<(Tlv<'a>, &'a [u8]), SignError> {
Tlv::read(input, Form::Der, 0)
}
/// Parse one BER element at the start of `input`; returns it and the rest.
pub fn parse_ber(input: &'a [u8]) -> Result<(Tlv<'a>, &'a [u8]), SignError> {
Tlv::read(input, Form::Ber, 0)
}
fn read(input: &'a [u8], form: Form, depth: usize) -> Result<(Tlv<'a>, &'a [u8]), SignError> {
let (&tag, rest) = input.split_first().ok_or_else(|| bad("truncated DER"))?;
if tag & 0x1F == 0x1F {
return Err(bad("multi-byte DER tags are not supported"));
}
let (&first, rest) = rest.split_first().ok_or_else(|| bad("truncated DER length"))?;
let (len, rest) = if first < 0x80 {
(first as usize, rest)
// `trailer` is the two end-of-contents octets, which belong to the element but not to its
// contents.
let (len, rest, trailer) = if first < 0x80 {
(first as usize, rest, 0)
} else if first == 0x80 {
if form == Form::Der {
return Err(bad("indefinite lengths are not allowed in DER"));
}
if tag & 0x20 == 0 {
return Err(bad("an indefinite length needs a constructed element"));
}
(contents_len(rest, form, depth)?, rest, 2)
} else {
let n = (first & 0x7F) as usize;
if n == 0 || n > 4 || rest.len() < n {
if n > 4 || rest.len() < n {
return Err(bad("unsupported DER length"));
}
let len = rest[..n].iter().fold(0usize, |acc, b| (acc << 8) | *b as usize);
(len, &rest[n..])
(len, &rest[n..], 0)
};
if rest.len() < len {
let end = len.checked_add(trailer).ok_or_else(|| bad("DER length overflows"))?;
if rest.len() < end {
return Err(bad("DER element runs past the end"));
}
// `end <= rest.len()`, so neither sum below can overflow.
let header = input.len() - rest.len();
Ok((Tlv { tag, value: &rest[..len], raw: &input[..header + len] }, &rest[len..]))
Ok((Tlv { tag, value: &rest[..len], raw: &input[..header + end], form }, &rest[end..]))
}
/// Parse `input` as exactly one element.
/// Parse `input` as exactly one DER element.
pub fn parse_all(input: &'a [u8]) -> Result<Tlv<'a>, SignError> {
let (t, rest) = Tlv::parse(input)?;
Tlv::only(input, Form::Der)
}
/// Parse `input` as exactly one BER element.
pub fn parse_all_ber(input: &'a [u8]) -> Result<Tlv<'a>, SignError> {
Tlv::only(input, Form::Ber)
}
fn only(input: &'a [u8], form: Form) -> Result<Tlv<'a>, SignError> {
let (t, rest) = Tlv::read(input, form, 0)?;
if !rest.is_empty() {
return Err(bad("trailing bytes after DER element"));
}
Ok(t)
}
/// The children of a constructed element.
/// The children of a constructed element, read with the same rules as their parent.
pub fn children(&self) -> Result<Vec<Tlv<'a>>, SignError> {
let mut out = Vec::new();
let mut rest = self.value;
while !rest.is_empty() {
let (t, r) = Tlv::parse(rest)?;
let (t, r) = Tlv::read(rest, self.form, 0)?;
out.push(t);
rest = r;
}
@@ -99,7 +159,34 @@ impl<'a> Tlv<'a> {
/// The element inside an EXPLICIT tag.
pub fn inner(&self) -> Result<Tlv<'a>, SignError> {
Tlv::parse_all(self.value)
Tlv::only(self.value, self.form)
}
/// An OCTET STRING's contents, joining the segments of BER's constructed form (X.690 §8.21).
/// Borrowed in the ordinary case, so the common path copies nothing.
pub fn octets(&self, what: &str) -> Result<Cow<'a, [u8]>, SignError> {
self.join(what, 0)
}
fn join(&self, what: &str, depth: usize) -> Result<Cow<'a, [u8]>, SignError> {
/// Deep enough for anything real: a writer that segments at all segments once.
const MAX_DEPTH: usize = 16;
match self.tag {
tag::OCTET_STRING => Ok(Cow::Borrowed(self.value)),
tag::OCTET_STRING_SEGMENTS if self.form == Form::Ber => {
if depth >= MAX_DEPTH {
return Err(bad(&format!("{what}: OCTET STRING segments nested too deeply")));
}
// The join is never larger than the segments it is made of, so this is bounded by
// the input.
let mut out: Vec<u8> = Vec::with_capacity(self.value.len());
for s in self.children()? {
out.extend_from_slice(&s.join(what, depth + 1)?);
}
Ok(Cow::Owned(out))
}
found => Err(bad(&format!("{what}: expected an OCTET STRING, found tag {found:#04x}"))),
}
}
/// An OID in dotted form.
@@ -172,6 +259,26 @@ impl<'a> Tlv<'a> {
}
}
/// How many contents octets an indefinite-length element has: everything up to the matching
/// end-of-contents octets, which have to be there (X.690 §8.1.5). The contents are elements, so
/// finding the end means reading them, and one of those may be indefinite too — hence the depth
/// limit. Every step consumes at least two octets, so the walk always ends.
fn contents_len(input: &[u8], form: Form, depth: usize) -> Result<usize, SignError> {
/// Far deeper than any real file nests, and shallow enough never to exhaust the stack.
const MAX_DEPTH: usize = 48;
if depth >= MAX_DEPTH {
return Err(bad("BER elements nested too deeply"));
}
let mut rest = input;
loop {
match rest {
[] => return Err(bad("an indefinite length with no end-of-contents")),
[0, 0, ..] => return Ok(input.len() - rest.len()),
_ => rest = Tlv::read(rest, form, depth + 1)?.1,
}
}
}
/// A UTC time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Time {
@@ -439,6 +546,36 @@ mod tests {
assert!(Tlv::parse(&[0x30, 5, 1]).is_err());
}
#[test]
fn reads_ber_indefinite_lengths_and_segmented_strings() {
// SEQUENCE { INTEGER 3, OCTET STRING { "hi", "!" } }, every wrapper in the indefinite form:
// the shape Windows gives a .p12 file.
let ber: &[u8] = &[0x30, 0x80, 0x02, 0x01, 0x03, 0x24, 0x80, 0x04, 0x02, b'h', b'i', 0x04, 0x01, b'!', 0, 0, 0, 0];
let e = Tlv::parse_all(ber).unwrap_err().to_string();
assert!(e.contains("indefinite lengths are not allowed in DER"), "{e}");
let t = Tlv::parse_all_ber(ber).unwrap();
assert_eq!(t.tag, tag::SEQUENCE);
assert_eq!(t.raw, ber, "the end-of-contents octets belong to the element");
assert_eq!(t.value, &ber[2..ber.len() - 2], "but not to its contents");
let c = t.children().unwrap();
assert_eq!(c[0].u64().unwrap(), 3);
assert_eq!(c[1].octets("segments").unwrap().as_ref(), b"hi!", "the segments are joined");
assert!(c[0].octets("not a string").is_err());
// Definite lengths read the same either way, and a segmented string stays a DER error.
assert_eq!(Tlv::parse_all_ber(&int(65537)).unwrap().u64().unwrap(), 65537);
assert_eq!(Tlv::parse_all_ber(&octets(b"hi!")).unwrap().octets("plain").unwrap().as_ref(), b"hi!");
assert!(Tlv::parse_all(&[0x24, 2, 0x04, 0]).unwrap().octets("segments").is_err());
// Malformed: no end-of-contents, and an indefinite length on a primitive element.
assert!(Tlv::parse_all_ber(&ber[..ber.len() - 2]).is_err());
assert!(Tlv::parse_all_ber(&[0x04, 0x80, 0, 0]).is_err());
// Nesting is bounded, so a file made of nothing but wrappers fails instead of crashing.
let deep = [[0x30u8, 0x80].repeat(400), [0u8, 0].repeat(400)].concat();
assert!(Tlv::parse_all_ber(&deep).is_err());
}
#[test]
fn times() {
let t = Time { year: 2026, month: 10, day: 2, hour: 9, minute: 30, second: 5 };
+39 -26
View File
@@ -7,12 +7,18 @@
//! base64 body (#159).
//! Writing uses PBES2 AES-256-CBC with PBKDF2-HMAC-SHA-256 and an HMAC-SHA-256 MAC, OpenSSL 3's
//! defaults.
//!
//! The envelope is read as BER, not DER: Windows writes `.p12` files with indefinite lengths and
//! the AuthenticatedSafe split into OCTET STRING segments, and nothing in the envelope is signed
//! over its own bytes. The certificates inside it are still read as strict DER, which is how they
//! were signed.
use std::borrow::Cow;
use base64::Engine as _;
use cbc::cipher::block_padding::Pkcs7;
use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyIvInit};
use hmac::Mac;
use std::borrow::Cow;
use crate::SignError;
use crate::der::{self, Tlv, tag};
@@ -182,7 +188,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
return Err(SignError::Unsupported("PBES2 key derivation other than PBKDF2".into()));
}
let kp = kdf.get(1).ok_or_else(|| bad("PBKDF2 parameters"))?.children()?;
let salt = kp.first().ok_or_else(|| bad("salt"))?.expect(tag::OCTET_STRING, "salt")?.value;
let salt = kp.first().ok_or_else(|| bad("salt"))?.octets("salt")?;
let rounds = kp.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32;
let prf = match kp
.iter()
@@ -208,13 +214,13 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
Some(DES_EDE3_CBC) => Cipher::TripleDes,
other => return Err(SignError::Unsupported(format!("PBES2 cipher {}", other.unwrap_or("?")))),
};
let iv = sc.get(1).ok_or_else(|| bad("IV"))?.expect(tag::OCTET_STRING, "IV")?.value;
let key = pbkdf2(prf, password.as_bytes(), salt, rounds, cipher.key_len());
cipher.decrypt(&key, iv, ct)
let iv = sc.get(1).ok_or_else(|| bad("IV"))?.octets("IV")?;
let key = pbkdf2(prf, password.as_bytes(), &salt, rounds, cipher.key_len());
cipher.decrypt(&key, &iv, ct)
}
PBE_SHA_3DES | PBE_SHA_2DES | PBE_SHA_RC2_128 | PBE_SHA_RC2_40 => {
let p = params.children()?;
let salt = p.first().ok_or_else(|| bad("salt"))?.expect(tag::OCTET_STRING, "salt")?.value;
let salt = p.first().ok_or_else(|| bad("salt"))?.octets("salt")?;
let rounds = p.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32;
let (cipher, key_len, iv_len) = match o.as_str() {
PBE_SHA_3DES => (Cipher::TripleDes, 24, 8),
@@ -223,13 +229,13 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
_ => (Cipher::Rc2(40), 5, 8),
};
let pw = bmp(password);
let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, salt, 1, rounds, key_len);
let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len);
if key.len() == 16 && matches!(cipher, Cipher::TripleDes) {
// Two-key 3DES: K1 K2 K1.
let k1 = key[..8].to_vec();
key.extend(k1);
}
let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, salt, 2, rounds, iv_len);
let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 2, rounds, iv_len);
cipher.decrypt(&key, &iv, ct)
}
other => Err(SignError::Unsupported(format!("PKCS #12 encryption {other}"))),
@@ -248,7 +254,7 @@ enum BagKind {
}
fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(), SignError> {
for bag in Tlv::parse_all(safe_contents)?.children()? {
for bag in Tlv::parse_all_ber(safe_contents)?.children()? {
let f = bag.children()?;
let Some(id) = f.first().and_then(|o| o.oid().ok()) else { continue };
let Some(value) = f.get(1).filter(|v| v.tag == tag::ctx(0)).and_then(|v| v.inner().ok()) else { continue };
@@ -258,7 +264,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(),
let p = a.children()?;
let v = p.get(1).and_then(|s| s.children().ok()).and_then(|s| s.into_iter().next());
match (p.first().and_then(|o| o.oid().ok()).as_deref(), v) {
(Some(LOCAL_KEY_ID), Some(v)) => local_key_id = Some(v.value.to_vec()),
(Some(LOCAL_KEY_ID), Some(v)) => local_key_id = v.octets("local key id").ok().map(Cow::into_owned),
(Some(FRIENDLY_NAME), Some(v)) => friendly_name = v.text(),
_ => {}
}
@@ -269,15 +275,15 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(),
SHROUDED_KEY_BAG => {
let p = value.children()?;
let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) };
BagKind::Key(pbe_decrypt(alg, password, data.value)?)
BagKind::Key(pbe_decrypt(alg, password, &data.octets("encrypted private key")?)?)
}
CERT_BAG => {
let p = value.children()?;
if p.first().and_then(|o| o.oid().ok()).as_deref() != Some(X509_CERT) {
continue;
}
let Some(c) = p.get(1).and_then(|c| c.inner().ok()) else { continue };
BagKind::Cert(c.value.to_vec())
let Some(c) = p.get(1).and_then(|c| c.inner().ok()).and_then(|c| c.octets("certificate").ok()) else { continue };
BagKind::Cert(c.into_owned())
}
_ => continue,
};
@@ -330,7 +336,7 @@ fn pem_body(pem: &[u8]) -> Result<&[u8], SignError> {
/// Open a `.p12` / `.pfx` file. A wrong password is [`SignError::WrongPassword`].
pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
let der = normalize(bytes)?;
let pfx = Tlv::parse_all(&der)
let pfx = Tlv::parse_all_ber(&der)
.map_err(|e| match e {
// Keep "not a PKCS #12 file" but carry the exact reason through.
SignError::Malformed(why) => bad(&format!("not a PKCS #12 file: {why}")),
@@ -341,38 +347,45 @@ pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
if auth_safe.first().map(|o| o.oid()).transpose()?.as_deref() != Some(DATA) {
return Err(SignError::Unsupported("public-key protected PKCS #12 files".into()));
}
let content = auth_safe.get(1).ok_or_else(|| bad("authSafe content"))?.inner()?.expect(tag::OCTET_STRING, "authSafe")?.value;
// The MAC proves the password before anything is decrypted.
let content = auth_safe.get(1).ok_or_else(|| bad("authSafe content"))?.inner()?.octets("authSafe")?;
// The MAC proves the password before anything is decrypted. It covers the contents of that
// OCTET STRING — the segments joined, if the file is segmented, which is what wrote them.
if let Some(mac) = pfx.get(2) {
let m = mac.children()?;
let digest_info = m.first().ok_or_else(|| bad("MacData"))?.children()?;
let alg = digest_info.first().ok_or_else(|| bad("MAC algorithm"))?.children()?;
let alg_oid = alg.first().ok_or_else(|| bad("MAC algorithm"))?.oid()?;
let alg = DigestAlg::from_oid(&alg_oid).ok_or_else(|| SignError::Unsupported(format!("MAC digest {alg_oid}")))?;
let expected = digest_info.get(1).ok_or_else(|| bad("MAC"))?.value;
let salt = m.get(1).ok_or_else(|| bad("MAC salt"))?.value;
let expected = digest_info.get(1).ok_or_else(|| bad("MAC"))?.octets("MAC")?;
let salt = m.get(1).ok_or_else(|| bad("MAC salt"))?.octets("MAC salt")?;
let iterations = m.get(2).map(|i| i.u64()).transpose()?.unwrap_or(1) as u32;
let key_len = alg.digest(&[]).len();
let key = pkcs12_kdf(alg, &bmp(password), salt, 3, iterations, key_len);
if hmac(alg, &key, content)? != expected {
let key = pkcs12_kdf(alg, &bmp(password), &salt, 3, iterations, key_len);
if hmac(alg, &key, &content)? != *expected {
return Err(SignError::WrongPassword);
}
}
let mut all = Vec::new();
for ci in Tlv::parse_all(content)?.children()? {
for ci in Tlv::parse_all_ber(&content)?.children()? {
let p = ci.children()?;
let kind = p.first().ok_or_else(|| bad("ContentInfo"))?.oid()?;
let body = p.get(1).ok_or_else(|| bad("ContentInfo content"))?.inner()?;
match kind.as_str() {
DATA => bags(body.expect(tag::OCTET_STRING, "SafeContents")?.value, password, &mut all)?,
DATA => bags(&body.octets("SafeContents")?, password, &mut all)?,
ENCRYPTED_DATA => {
let ed = body.children()?;
let eci = ed.get(1).ok_or_else(|| bad("EncryptedContentInfo"))?.children()?;
let alg = eci.get(1).ok_or_else(|| bad("content encryption algorithm"))?;
let ct = match eci.get(2) {
Some(t) if t.tag == tag::ctx_prim(0) => t.value.to_vec(),
// Constructed form: a sequence of OCTET STRING segments.
Some(t) if t.tag == tag::ctx(0) => t.children()?.iter().flat_map(|s| s.value.iter().copied()).collect(),
let ct: Cow<'_, [u8]> = match eci.get(2) {
Some(t) if t.tag == tag::ctx_prim(0) => Cow::Borrowed(t.value),
// Constructed form: the OCTET STRING segments, joined.
Some(t) if t.tag == tag::ctx(0) => {
let mut v = Vec::with_capacity(t.value.len());
for s in t.children()? {
v.extend_from_slice(&s.octets("encryptedContent")?);
}
Cow::Owned(v)
}
_ => return Err(bad("encryptedContent")),
};
let plain = pbe_decrypt(alg, password, &ct)?;
+69 -1
View File
@@ -1,6 +1,6 @@
//! The cryptographic core against files made by OpenSSL 3 (tests/data/README.md).
use pdfcraft_sign::der::Time;
use pdfcraft_sign::der::{Time, Tlv};
use pdfcraft_sign::keys::DigestAlg;
use pdfcraft_sign::{Certificate, Name, PublicKey, SignError, cms, pkcs12};
@@ -58,6 +58,74 @@ fn opens_every_openssl_flavour_of_pkcs12() {
assert_eq!(chain.certificate.key_usage.map(|u| u & 0b11), Some(0b11), "digitalSignature + nonRepudiation");
}
/// One element with BER's indefinite length: contents, then the end-of-contents octets.
fn indefinite(tag: u8, body: &[u8]) -> Vec<u8> {
let mut v = vec![tag, 0x80];
v.extend_from_slice(body);
v.extend([0, 0]);
v
}
/// An OCTET STRING split into segments, BER's constructed form.
fn segmented(bytes: &[u8]) -> Vec<u8> {
let parts: Vec<u8> = bytes.chunks(64).flat_map(der::octets).collect();
indefinite(0x24, &parts)
}
/// A `.p12` in the shape Windows writes one: the PFX, its ContentInfo and the `[0]` holding the
/// AuthenticatedSafe all in the indefinite form, and the AuthenticatedSafe split into segments.
/// Nothing changes meaning, and the MAC still covers the same octets, so whatever a reader makes
/// of it, it is making it of the form and not of the contents.
///
/// With `deep`, every ContentInfo inside is rewritten the same way, as a real Windows file has it.
/// That re-encodes the AuthenticatedSafe, which is what the MAC covers, so the MacData is dropped
/// — RFC 7292 §4 makes it optional — and a wrong password is then caught by the decryption.
fn as_windows_writes_it(der: &[u8], deep: bool) -> Vec<u8> {
let pfx = Tlv::parse_all(der).unwrap().children().unwrap();
let auth = pfx[1].children().unwrap();
let mut content = auth[1].inner().unwrap().value.to_vec();
if deep {
let mut inner = Vec::new();
for ci in Tlv::parse_all(&content).unwrap().children().unwrap() {
let p = ci.children().unwrap();
let body = p[1].inner().unwrap();
// A Data content is an OCTET STRING, so it gets segmented; an EncryptedData is a
// SEQUENCE and stays as it is inside its indefinite wrappers.
let wrapped = if body.tag == 0x04 { segmented(body.value) } else { body.raw.to_vec() };
let zero = indefinite(0xA0, &wrapped);
inner.extend(indefinite(0x30, &[p[0].raw, zero.as_slice()].concat()));
}
content = indefinite(0x30, &inner);
}
let zero = indefinite(0xA0, &segmented(&content));
let mut body = pfx[0].raw.to_vec();
body.extend(indefinite(0x30, &[auth[0].raw, zero.as_slice()].concat()));
if !deep {
for extra in &pfx[2..] {
body.extend_from_slice(extra.raw);
}
}
indefinite(0x30, &body)
}
#[test]
fn opens_pkcs12_as_windows_writes_it() {
for file in ["rsa-aes.p12", "rsa-legacy.p12", "chain.p12"] {
let der = data(file);
let want = pkcs12::open(&der, "test").unwrap();
for deep in [false, true] {
let ber = as_windows_writes_it(&der, deep);
assert_ne!(ber, der, "{file} ({deep})");
let got = pkcs12::open(&ber, "test").unwrap_or_else(|e| panic!("{file} in BER (deep: {deep}): {e}"));
assert_eq!(got.certificate.raw, want.certificate.raw, "{file} ({deep})");
assert_eq!(got.key.public_key(), want.key.public_key(), "{file} ({deep})");
assert_eq!(got.chain.len(), want.chain.len(), "{file} ({deep})");
assert_eq!(got.friendly_name, want.friendly_name, "{file} ({deep})");
assert!(matches!(pkcs12::open(&ber, "nope"), Err(SignError::WrongPassword)), "{file} ({deep})");
}
}
}
#[test]
fn certificates_parse_as_openssl_made_them() {
let c = Certificate::parse(&pem("rsa.crt.pem")).unwrap();
+1 -1
View File
@@ -12,6 +12,7 @@ workspace = true
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-platform = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true }
@@ -57,7 +58,6 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE
[dev-dependencies]
pdfcraft-xfa = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
+19 -8
View File
@@ -991,21 +991,28 @@ pub struct PageXform {
}
impl PageXform {
/// Draw an image in PDF user space, preserving orientation through both page rotations.
/// Draw an image in the PDF user-space `rect` as a custom stamp's appearance draws it: turned
/// back by `turn` (the page /Rotate it was placed for), so with the page's own rotation it
/// reads upright as displayed.
pub(crate) fn paint_user_image(
&self,
painter: &egui::Painter,
tex: egui::TextureId,
info: &DocInfo,
page: usize,
p: &pdfcraft_render::PageInfo,
rect: [f64; 4],
turn: i64,
color: Color32,
) {
let Some(p) = info.pages.get(page) else { return };
let mut mesh = egui::Mesh::with_texture(tex);
for (x, y, u, v) in [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)] {
let p = p.user_to_view(x as f32, y as f32);
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(p[0] / self.pw, p[1] / self.ph), uv: pos2(u, v), color });
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
let (shown_w, shown_h) = if turn % 180 == 0 { (w, h) } else { (h, w) };
let [a, b, c, d, e, f] = pdfcraft_model::view_matrix_for(turn, rect);
for (u, v) in [(0.0_f32, 0.0_f32), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
// The texture's (u, v), from its top-left, in the picture's upright frame, then in user space.
let (dx, dy) = (f64::from(u) * shown_w, f64::from(1.0 - v) * shown_h);
let (x, y) = (a * dx + c * dy + e, b * dx + d * dy + f);
let q = p.user_to_view(x as f32, y as f32);
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(q[0] / self.pw, q[1] / self.ph), uv: pos2(u, v), color });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
@@ -2001,7 +2008,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let field_props = field_props.then(|| view.prepare.selected.clone()).flatten();
match canvas_action {
Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e),
Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments),
// `choose_right_panel` would borrow all of `app` while `view` is held; same effect.
Some(comments::CanvasAction::OpenComments) => {
app.right = Some(RightPanel::Comments);
app.comments_panel_closed = false;
}
Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)),
None => {}
}
+54 -26
View File
@@ -28,31 +28,59 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.active = None;
app.combine_tab.focused = false;
}
// Keep the two 28-point buttons, Discord's text/padding and three gaps outside the scrolling tabs.
let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 106.0;
let state = (app.active, app.views.len(), app.combine_showing());
let changed = ui.data_mut(|data| {
let id = ui.id().with("active_tab");
let changed = data.get_temp::<(Option<usize>, usize, bool)>(id) != Some(state);
data.insert_temp(id, state);
changed
});
let mut close = None;
for i in 0..app.views.len() {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let (name, dirty) = (doc.display_name(), doc.dirty);
if tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i).clicked() {
app.active = Some(i);
}
}
if let Some(i) = close {
app.request_close_tab(i);
}
if app.combine_tab.open {
// After the document tabs; its index can't clash with theirs.
let mut close = None;
if tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX).clicked() {
app.open_combine_tab();
}
if close.is_some() {
app.close_combine_tab();
}
}
ui.add_space(4.0);
if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() {
app.open_dialog();
}
ui.scope(|ui| {
ui.style_mut().always_scroll_the_only_direction = true;
egui::ScrollArea::horizontal()
.id_salt("document_tabs")
.max_width((ui.available_width() - controls_width).max(0.0))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
for i in 0..app.views.len() {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let (name, dirty) = (doc.display_name(), doc.dirty);
let response = tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i);
if changed && app.active == Some(i) && !app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
if response.clicked() {
app.active = Some(i);
}
}
if let Some(i) = close {
app.request_close_tab(i);
}
if app.combine_tab.open {
// After the document tabs; its index can't clash with theirs.
let mut close = None;
let response = tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX);
if changed && app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
if response.clicked() {
app.open_combine_tab();
}
if close.is_some() {
app.close_combine_tab();
}
}
ui.add_space(4.0);
if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() {
app.open_dialog();
}
});
});
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (icon, label) = match app.theme_preference {
ThemePreference::System => ("settings", tl!("Use system setting")),
@@ -240,7 +268,7 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
(RightPanel::Compare, tl!("Compare")),
] {
if ui.radio(app.right == Some(p), tl!(label)).clicked() {
app.right = Some(p);
app.choose_right_panel(Some(p));
}
}
});
@@ -278,7 +306,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.painter().circle_filled(c, 3.0, t.accent);
}
if r.clicked() {
app.right = if selected { None } else { Some(panel) };
app.choose_right_panel(if selected { None } else { Some(panel) });
}
};
rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
+3 -3
View File
@@ -180,13 +180,13 @@ impl PdfCraftApp {
"view.theme.system" => self.set_theme_preference(ThemePreference::System),
"view.theme.light" => self.set_theme_preference(ThemePreference::Light),
"view.theme.dark" => self.set_theme_preference(ThemePreference::Dark),
"comment.list" => self.right = Some(RightPanel::Comments),
"comment.list" => self.choose_right_panel(Some(RightPanel::Comments)),
tool if crate::comments::CommentTool::from_command(tool).is_some() => {
let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false };
self.comment_prefs.group_tool[tool.group()] = tool;
self.quick_tool = crate::QuickTool::Comment(tool);
// Acrobat opens the Comments panel with the commenting tools.
if self.right.is_none() {
// Acrobat opens the Comments panel with the commenting tools, unless the user closed it.
if self.right.is_none() && !self.comments_panel_closed {
self.right = Some(RightPanel::Comments);
}
// A text selection made before picking a markup tool is marked right away.
+3 -5
View File
@@ -808,11 +808,9 @@ fn select_input(
&& let Some(o) = origin
{
if let (Some(h), Some(a)) = (handle_at(o), selected) {
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).and_then(|ratio| {
let p = cx.info.pages.get(cx.page)?;
let rotation = u32::from(p.rotation) + u32::from(cx.xf.rot);
Some(if rotation % 180 == 0 { ratio } else { ratio.recip() })
});
// On screen, the image is also turned by the view's rotation.
let turned = !cx.xf.rot.is_multiple_of(180);
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio });
consumed = true;
} else if let Some(a) = cx.hit(o)
+82 -30
View File
@@ -1,7 +1,7 @@
//! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page.
//! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the
//! box, Esc cancels); drag it to move it, or drag the handle on its right edge to rewrap it to a
//! new width. Click an image to select it: drag to move, drag a corner to resize (keeping
//! box, Esc cancels); drag it to move it, or drag the handle on its left or right edge to rewrap
//! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping
//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it.
use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke};
@@ -170,19 +170,56 @@ pub struct ImageSelection {
drag: Option<(Pos2, Option<Pos2>)>,
}
/// A paragraph box being dragged: moved, or (from the handle on its right edge) resized.
/// What a drag on a paragraph box takes hold of.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grip {
Move,
/// An edge (screen left or right), dragged to rewrap the paragraph.
Left,
Right,
}
/// A paragraph box being dragged: moved, or (from a handle on its left or right edge) resized.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlockDrag {
page: usize,
block: usize,
/// Where the drag started (screen).
start: Pos2,
resize: bool,
grip: Grip,
}
/// The resize handle on a paragraph box's right edge (screen).
fn width_handle(b: Rect) -> Rect {
Rect::from_center_size(Pos2::new(b.right(), b.center().y), egui::vec2(7.0, 14.0))
/// How far an edge grip reaches either side of a box's edge (screen pixels).
const EDGE_REACH: f32 = 6.0;
/// The rewrap handles drawn on a paragraph box's left and right edges (screen).
fn width_handles(b: Rect) -> [Rect; 2] {
let h = (b.height() * 0.5).clamp(14.0, 28.0);
[b.left(), b.right()].map(|x| Rect::from_center_size(Pos2::new(x, b.center().y), egui::vec2(7.0, h)))
}
/// The edge of `b` that `p` grabs: anywhere along its height, within reach of the line. A narrow
/// box keeps its middle for moving.
fn edge_at(b: Rect, p: Pos2) -> Option<Grip> {
if p.y < b.top() - EDGE_REACH || p.y > b.bottom() + EDGE_REACH {
return None;
}
let inside = EDGE_REACH.min(b.width() / 4.0);
let (dl, dr) = (p.x - b.left(), b.right() - p.x);
match (dl >= -EDGE_REACH && dl <= inside, dr >= -EDGE_REACH && dr <= inside) {
(true, true) if dl <= dr => Some(Grip::Left),
(_, true) => Some(Grip::Right),
(true, false) => Some(Grip::Left),
_ => None,
}
}
/// The topmost box under `p` and what it grabs there; edges only on an upright page.
fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> {
boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() {
Some(g) => Some((i, g)),
None => b.contains(p).then_some((i, Grip::Move)),
})
}
/// What a right-click on a selected image asks for.
@@ -360,25 +397,38 @@ pub(crate) fn page_input(
// The width handle needs the page upright (or upside down): on a quarter-turned page the
// screen's horizontal is the paragraph's vertical.
let upright = info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0);
// A drag in progress: the box follows the pointer (or its right edge does); releasing applies it.
let draw_handles = |b: Rect| {
for h in width_handles(b) {
painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
}
};
// A drag in progress: the box follows the pointer (or the grabbed edge does); releasing applies it.
if let Some(d) = view.block_drag.filter(|d| d.page == page)
&& let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block))
{
let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start);
let preview = if d.resize {
Rect::from_min_max(b.min, Pos2::new((b.right() + p.x - d.start.x).max(b.left() + 12.0), b.max.y))
} else {
b.translate(p - d.start)
let dx = p.x - d.start.x;
let preview = match d.grip {
Grip::Move => b.translate(p - d.start),
Grip::Left => Rect::from_min_max(Pos2::new((b.left() + dx).min(b.right() - 12.0), b.min.y), b.max),
Grip::Right => Rect::from_min_max(b.min, Pos2::new((b.right() + dx).max(b.left() + 12.0), b.max.y)),
};
painter.rect_stroke(preview, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
ui.ctx().set_cursor_icon(if d.resize { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing });
let resizing = d.grip != Grip::Move;
if resizing {
draw_handles(preview);
}
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing });
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
view.block_drag = None;
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
let style = if d.resize {
// The new width in user space: the box's change, added to the paragraph's own.
let style = if resizing {
// The new width in user space: the box's change, added to the paragraph's own. A
// moved left side (in user space; on an upside-down page that's the screen's right
// edge) moves the paragraph with it.
let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]);
pdfcraft_engine::BlockStyle { width: Some(width), ..Default::default() }
let shift = to[0] - from[0];
pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }
} else {
pdfcraft_engine::BlockStyle { offset: Some([to[0] - from[0], to[1] - from[1]]), ..Default::default() }
};
@@ -388,26 +438,28 @@ pub(crate) fn page_input(
}
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().rposition(|b| b.contains(p) || (upright && width_handle(*b).contains(p))) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
let on_handle = upright && active.is_none() && width_handle(boxes[hit]).contains(p);
if upright && active.is_none() {
painter.rect(width_handle(boxes[hit]), CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
}
ui.ctx().set_cursor_icon(if on_handle { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text });
// Dragging a box (not while a paragraph is open for typing) moves it; from the handle, resizes it.
// Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it.
// What it grabs comes from where the button went down, not where the pointer is now: egui
// only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a
// quick flick has left the edge by then.
if resp.drag_started()
&& active.is_none()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
&& let Some(block) = boxes.iter().rposition(|b| b.contains(o) || (upright && width_handle(*b).contains(o)))
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
{
let resize = upright && width_handle(boxes[block]).contains(o);
view.block_drag = Some(BlockDrag { page, block, start: o, resize });
view.block_drag = Some(BlockDrag { page, block, start: o, grip });
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
let on_edge = active.is_none() && grip != Grip::Move;
if upright && active.is_none() {
draw_handles(boxes[hit]);
}
ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text });
if resp.clicked() {
let l = &lines[hit];
// Screen pixels per point, from the box's width.
+115 -1
View File
@@ -57,6 +57,14 @@ impl PdfCraftApp {
let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max();
view.comments.selected = newest.map(|n| (a.page, n));
view.comments.reveal = true;
// A new highlight opens its note for typing straight away, as in Acrobat,
// unless a note typed into another card is still unsaved.
if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest)
&& a.contents.is_empty()
&& view.comments.editing.as_ref().is_none_or(|(_, _, text)| text.is_empty())
{
view.comments.editing = Some((a.page, n, String::new()));
}
}
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
_ => {}
@@ -419,7 +427,13 @@ impl PdfCraftApp {
if let Some(i) = self.active
&& let Some(d) = self.session.get(id)
{
self.views[i].document_changed(&d.info);
let view = &mut self.views[i];
view.document_changed(&d.info);
// Revert discards this source's uncommitted typing as well as model edits.
// Otherwise a later Save could write a rejected draft back into the file.
view.forms.focus = None;
view.forms.committed = None;
view.pending_edit = None;
}
self.notify_tr("Reverted to the last saved version");
}
@@ -450,6 +464,14 @@ impl PdfCraftApp {
}
},
};
// Quitting closes the unsaved tabs one by one (and brings each forward to save it):
// remember what was open, and which tab was active, before the first one goes (#442).
if req == CloseRequest::Quit {
match choice {
Some(_) => self.note_quit_session(),
None => self.forget_quit_session(),
}
}
match choice {
None => {} // cancelled: nothing closes
Some(false) => self.close_and_continue(ctx, index, req),
@@ -788,3 +810,95 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod revert_draft_tests {
use super::*;
use crate::forms_ui::Focus;
use pdfcraft_engine::FieldValue;
fn focus(text: &str) -> Focus {
Focus {
name: "name".into(),
widget: 0,
text: text.into(),
picked: Vec::new(),
request_focus: false,
select_all: false,
calendar: None,
calendar_rect: None,
}
}
fn app() -> PdfCraftApp {
let mut app = PdfCraftApp::new();
app.open_bytes("source.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
app
}
fn queue_draft(app: &mut PdfCraftApp, index: usize, text: &str) {
app.views[index].forms.focus = Some(focus(text));
let form = app.session.get(app.views[index].id).unwrap().form.clone();
crate::forms_ui::commit(&mut app.views[index], &form);
assert!(app.views[index].forms.committed.is_some() && app.views[index].pending_edit.is_some());
}
#[test]
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
for queued in [false, true] {
let mut app = app();
let source = app.views[0].id;
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Committed change".into()) }));
if queued {
queue_draft(&mut app, 0, "Queued source draft");
} else {
app.views[0].forms.focus = Some(focus("Focused source draft"));
}
app.open_bytes("other.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
let other = app.views[1].id;
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Other committed value".into()) }));
queue_draft(&mut app, 1, "Other queued draft");
app.views[1].forms.focus = Some(focus("Other focused draft"));
let other_bytes = app.session.get(other).unwrap().bytes.clone();
let other_generation = app.session.get(other).unwrap().edit_generation();
let source_generation = app.session.get(source).unwrap().edit_generation();
app.active = Some(0);
app.revert_active();
let reverted = app.session.get(source).unwrap();
assert!(!reverted.dirty);
assert_eq!(reverted.edit_generation(), source_generation + 1);
assert!(reverted.form.iter().find(|field| field.name == "name").unwrap().value.is_empty());
assert!(app.views[0].forms.focus.is_none() && app.views[0].forms.committed.is_none() && app.views[0].pending_edit.is_none());
assert!(!app.has_unsaved_work(0));
assert_eq!(app.views[1].forms.focus.as_ref().unwrap().text, "Other focused draft");
assert_eq!(app.views[1].forms.committed.as_ref().unwrap().text, "Other queued draft");
assert!(
matches!(&app.views[1].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Other queued draft")
);
let untouched = app.session.get(other).unwrap();
assert_eq!(untouched.bytes, other_bytes);
assert_eq!(untouched.edit_generation(), other_generation);
assert!(untouched.dirty && app.has_unsaved_work(1));
assert_eq!(app.active_ids(), Some((0, source)));
}
}
#[test]
fn failed_revert_preserves_focused_and_queued_drafts() {
let mut app = app();
let source = app.views[0].id;
queue_draft(&mut app, 0, "Queued draft");
app.views[0].forms.focus = Some(focus("Focused draft"));
// Exercise the ordinary NoDocument error boundary without a corrupt fixture.
// This verifies UI failure preservation, not other engine refresh failures.
app.session.close(source);
app.revert_active();
assert_eq!(app.views[0].forms.focus.as_ref().unwrap().text, "Focused draft");
assert_eq!(app.views[0].forms.committed.as_ref().unwrap().text, "Queued draft");
assert!(
matches!(&app.views[0].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Queued draft")
);
assert_eq!(app.active_ids(), Some((0, source)));
assert_eq!(app.toast.as_ref().unwrap().0, pdfcraft_engine::EditError::NoDocument.to_string());
}
}
+8 -7
View File
@@ -5,7 +5,7 @@
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style};
use pdfcraft_render::DocInfo;
use pdfcraft_render::{DocInfo, PageInfo};
use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
@@ -212,10 +212,10 @@ pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, au
}
/// Place a saved signature or initials.
pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
match sig {
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
SavedSig::Image(image) => image.edit(page, at, initials, author),
SavedSig::Image(image) => image.edit(page, info, at, initials, author),
SavedSig::Typed(text) => {
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
let height = if initials { 24.0_f64 } else { 32.0_f64 };
@@ -373,6 +373,7 @@ pub(crate) fn page_input(
if !resp.contains_pointer() || !xf.rect.contains(pointer) {
return None;
}
let p = info.pages.get(page)?;
let at = to_user(xf, info, page, pointer);
let saved = match tool {
FillTool::Signature => signature,
@@ -380,9 +381,9 @@ pub(crate) fn page_input(
_ => None,
};
if let Some(SavedSig::Image(image)) = saved {
if let Some(rect) = image.rect(at, tool == FillTool::Initials) {
if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) {
let tex = image_texture(ui, image, preview);
xf.paint_user_image(ui.painter(), tex, info, page, rect, Color32::WHITE);
xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE);
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
}
} else {
@@ -401,11 +402,11 @@ pub(crate) fn page_input(
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
}
FillTool::Signature => match signature {
Some(s) => place(page, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
Some(s) => place(page, p, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
None => Some(FillAction::CreateSignature),
},
FillTool::Initials => match initials {
Some(s) => place(page, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
Some(s) => place(page, p, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
None => Some(FillAction::CreateInitials),
},
mark => {
+3 -2
View File
@@ -364,11 +364,12 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
18.0,
egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),
);
ui.painter().text(rect.min + vec2(62.0, 19.0), Align2::LEFT_CENTER, crate::bidi::visual(&f.name), theme::medium(13.0), t.text);
let size = crate::panels::human_size(usize::try_from(f.size).unwrap_or(usize::MAX));
let when = f.modified.map(crate::combine_ui::ago).unwrap_or_default();
let detail = if when.is_empty() { size } else { format!("{when} · {size}") };
ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted);
let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted);
let width = detail.left() - 16.0 - (rect.left() + 62.0);
widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width);
if resp.on_hover_text(&f.path).clicked() {
open = Some(f.path.clone());
}
+6 -4
View File
@@ -123,16 +123,18 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
22.0,
egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),
);
ui.painter().text(rect.min + vec2(46.0, 15.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.name), theme::medium(13.5), t.text);
ui.painter().text(rect.min + vec2(46.0, 32.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint);
ui.painter().text(
let detail = ui.painter().text(
rect.right_center() - vec2(12.0, 0.0),
Align2::RIGHT_CENTER,
format!("{} {} · {}", r.pages, tl!("pages"), human_size(r.size)),
theme::regular(12.0),
t.text_muted,
);
if resp.clicked() {
// The name and path stop short of the page count; hovering shows the whole path.
let width = detail.left() - 16.0 - (rect.left() + 46.0);
widgets::row_text(ui, rect.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width);
widgets::row_text(ui, rect.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width);
if resp.on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -108,6 +108,7 @@
Fit to width scrolling Plynulé posouvání s přizpůsobením šířce
Fit one full page Celá jedna stránka
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče.
Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Vyberte obrázek PNG nebo JPEG (nejvýše 4 MiB). Průhlednost bude zachována.
Couldn't import the signature image: {e} Obrázek podpisu nelze importovat: {e}
Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Přenosný režim je vypnutý: do složky {folder} nelze zapisovat ({error}). Nastavení se místo toho ukládá do vaší uživatelské složky.
Can't render this file because it contains an unexpected character in line 7 and column 81.
+1
View File
@@ -1986,6 +1986,7 @@
Default page display Presentación de página predeterminada
Default zoom Zoom predeterminado
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página.
Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez
Created a PDF from {n} files Se creó un PDF a partir de {n} archivos
PDF, images and text PDF, imágenes y texto
Insert a file before page {label} Insertar un archivo antes de la página {label}
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -1909,6 +1909,7 @@
Default page display Affichage des pages par défaut
Default zoom Zoom par défaut
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre.
Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft
Show Afficher
Sort Trier
Reverse the order Inverser l'ordre
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -1975,3 +1975,4 @@
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG または JPEG 画像を選択してください(最大 4 MiB)。透明度は保持されます。
Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e}
Go to page… ページへ移動…
Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く
Can't render this file because it contains an unexpected character in line 6 and column 47.
+117 -6
View File
@@ -87,7 +87,7 @@ fn plural_russian(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 10] = [
pub static LANGUAGES: [LangInfo; 11] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@@ -104,6 +104,8 @@ pub static LANGUAGES: [LangInfo; 10] = [
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, catalog: OnceLock::new() },
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, catalog: OnceLock::new() },
// Bulgarian; every `bg-*` locale (`bg-BG`) resolves here.
LangInfo { code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// Telugu; every `te-*` locale (`te-IN`) resolves here.
LangInfo { code: "te", name: "తెలుగు", source: include_str!("te.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
];
@@ -218,7 +220,9 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
/// The system language (cached). English when it can't be determined.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the developer's locale is.
// Tests drive the UI by its English labels whatever the developer's locale is. This covers
// the unit tests; the integration tests link the library as it ships, where `cfg!(test)` is
// off, so each of their harnesses pins the `language` option to `en` instead.
if cfg!(test) {
return Lang::EN;
}
@@ -450,10 +454,14 @@ mod tests {
#[cfg(target_os = "windows")]
#[test]
fn windows_display_language_query_returns_a_locale_tag() {
let tag = windows_ui_language().expect("Windows should report a display language");
let tag = tag.trim();
assert!(!tag.is_empty());
assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())));
// One tag per line, as `first_supported` reads them: a machine with several display
// languages reports them all, so each line is checked rather than the list as a whole.
let list = windows_ui_language().expect("Windows should report a display language");
let tags: Vec<&str> = list.lines().map(str::trim).filter(|t| !t.is_empty()).collect();
assert!(!tags.is_empty(), "no display language in {list:?}");
for tag in tags {
assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())), "not a locale tag: {tag:?}");
}
}
#[test]
@@ -1223,6 +1231,109 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn bulgarian_is_registered() {
let bg = Lang::from_code("bg").expect("bg registered");
assert_eq!(bg.name(), "Български");
assert_eq!(normalize_pref("BG"), Some("bg"));
assert_eq!(lang_from_tag("bg_BG.UTF-8"), Some(bg));
assert_eq!(lang_from_tag("bg-BG"), Some(bg));
assert_eq!(first_supported("bg-BG\r\nen-US"), Some(bg));
assert_eq!(tr(bg, "File"), "Файл");
assert_eq!(tr(bg, "Save as…"), "Запиши като…");
assert_eq!(tr(bg, "Bookmarks"), "Отметки");
assert_eq!(tr(bg, "Layers"), "Слоеве");
assert_eq!(tr(bg, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf");
// one (1), other (0, 2+)
assert_eq!((0..=3).map(|n| (bg.0.plural)(n)).collect::<Vec<_>>(), [1, 0, 1, 1]);
assert_eq!(trn(bg, 0, "{n} page", "{n} pages"), "0 страници");
assert_eq!(trn(bg, 1, "{n} page", "{n} pages"), "1 страница");
assert_eq!(trn(bg, 2, "{n} page", "{n} pages"), "2 страници");
assert_eq!(trn(bg, 21, "{n} page", "{n} pages"), "21 страници");
assert_eq!(trn(bg, 1, "{n} field", "{n} fields"), "1 поле");
assert_eq!(trn(bg, 5, "{n} field", "{n} fields"), "5 полета");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "bg").unwrap();
assert_eq!(app.language, "bg");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "bg");
}
/// Bulgarian translates every registered command and every All tools group, section and item.
#[test]
fn bulgarian_covers_commands_and_catalogue() {
let bg = Lang::from_code("bg").expect("bg registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(bg, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(bg, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(bg, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(bg, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(bg, item.label), "missing item: {}", item.label);
}
}
}
}
/// New tl!("literal") labels must not silently fall back to English.
#[test]
fn bulgarian_covers_ui_literals() {
let bg = Lang::from_code("bg").expect("bg registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
if tail.starts_with("\")") {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(bg, label)).collect();
assert!(missing.is_empty(), "untranslated Bulgarian UI literals: {missing:#?}");
}
#[test]
fn bulgarian_diagnostics_preserve_user_values() {
let bg = Lang::from_code("bg").expect("bg registered");
set_current(bg);
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Избрани страници: 3");
assert_eq!(tr(bg, "CheckBox"), "Квадратче за отметка");
set_current(Lang::EN);
}
/// Every bundled catalog is well-formed and consistent with its sources.
#[test]
fn bundled_catalogs_are_consistent() {
+1
View File
@@ -66,3 +66,4 @@
Couldn't import the signature image: {e} Não foi possível importar a imagem da assinatura: {e}
Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. O modo portátil está desativado: não é possível gravar em {folder} ({error}). As configurações ficam na sua pasta de usuário.
Go to page… Ir para a página…
Reopen the files that were open when PdfCraft last closed Reabrir os arquivos que estavam abertos quando o PdfCraft foi fechado pela última vez
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1972,6 +1972,7 @@
Default page display Отображение страниц по умолчанию
Default zoom Масштаб по умолчанию
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами.
Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Выберите изображение PNG или JPEG (до 4 МиБ). Прозрачность сохраняется.
Couldn't import the signature image: {e} Не удалось импортировать изображение подписи: {e}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1911,6 +1911,7 @@ action wizard Start ప్రారంభించు
Default page display డిఫాల్ట్ పేజీ ప్రదర్శన
Default zoom డిఫాల్ట్ జూమ్
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు.
Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు
About పరిచయం
Contributors సహకారులు
Models మోడల్‌లు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -2005,6 +2005,7 @@
Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). 尚未安装文本识别功能:缺少模型(请运行 `cargo xtask models` 或设置 PDFCRAFT_MODELS)。
Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。
Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件
Write this email? 是否撰写此邮件?
{who} in this document wants to open this address in your browser: 此文档中的{who}请求在浏览器中打开以下地址:
{who} in this document wants to open your email app with this address: 此文档中的{who}请求打开邮件应用并使用以下地址:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1968,3 +1968,4 @@
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 選擇 PNG 或 JPEG 圖像(最大 4 MiB)。透明度會保留。
Couldn't import the signature image: {e} 無法匯入簽名圖像:{e}
Go to page… 轉到頁面…
Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -295,6 +295,9 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
.small()
.color(t.text_muted),
);
// The web build has no file paths to reopen.
#[cfg(not(target_arch = "wasm32"))]
ui.checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"));
ui.add_space(8.0);
// Identity: the author of new comments (Acrobat: Preferences ▸ Identity).
ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0)));
+159
View File
@@ -0,0 +1,159 @@
//! Reopen the files from the last session (Preferences ▸ Documents and view, #442): which files
//! were open when PdfCraft last closed, the page each showed and the active tab. Local only, like
//! the recent-files list, and kept only while the preference is on.
use serde::Serialize;
use crate::PdfCraftApp;
/// More files than anyone keeps open. Settings are untrusted, so a longer list is cut here.
pub const MAX_FILES: usize = 64;
/// The files open when PdfCraft last closed.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct LastSession {
pub files: Vec<SessionFile>,
/// The path of the tab that was active.
pub active: Option<String>,
}
/// One open file and where the reader was in it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SessionFile {
/// Absolute, so a file opened from the command line by a relative path is found again.
pub path: String,
/// The page the tab showed (0-based).
pub page: usize,
}
impl LastSession {
/// Read what [`PdfCraftApp::persist`] wrote. Malformed entries are skipped and the list is
/// capped at [`MAX_FILES`].
pub(crate) fn from_json(v: &serde_json::Value) -> Self {
let files = v["files"]
.as_array()
.map(|files| {
files
.iter()
.filter_map(|f| {
let path = f["path"].as_str().filter(|p| !p.is_empty())?.to_string();
// `go_to_page` clamps a page past the end.
let page = f["page"].as_u64().and_then(|p| usize::try_from(p).ok()).unwrap_or(0);
Some(SessionFile { path, page })
})
.take(MAX_FILES)
.collect()
})
.unwrap_or_default();
Self { files, active: v["active"].as_str().map(str::to_string) }
}
}
/// `path` made absolute against the current directory, without resolving links (and without
/// Windows' `\\?\` prefix, which `canonicalize` adds).
#[cfg(not(target_arch = "wasm32"))]
fn absolute(path: &str) -> String {
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
}
impl PdfCraftApp {
/// The files open now. Documents that were never saved to a file are left out (crash
/// recovery keeps their unsaved work), and so is everything on the web, which has no paths.
pub(crate) fn open_session(&self) -> LastSession {
#[cfg(not(target_arch = "wasm32"))]
{
let path = |v: &crate::DocView| self.session.get(v.id).and_then(|d| d.path.as_deref()).map(absolute);
let files = self.views.iter().filter_map(|v| Some(SessionFile { path: path(v)?, page: v.current })).take(MAX_FILES).collect();
let active = self.active.and_then(|i| self.views.get(i)).and_then(path);
LastSession { files, active }
}
#[cfg(target_arch = "wasm32")]
LastSession::default()
}
/// What to save as the last session: the files open when the quit began, once the app is
/// really quitting (unsaved tabs close one by one before that), else the files open now.
pub(crate) fn session_to_save(&self) -> LastSession {
match &self.quit_session {
Some(s) if self.allow_quit => s.clone(),
_ => self.open_session(),
}
}
/// A quit is about to close its first tab: remember everything that is still open.
pub(crate) fn note_quit_session(&mut self) {
if self.quit_session.is_none() {
self.quit_session = Some(self.open_session());
}
}
/// The quit was cancelled: what is open from now on is the session again.
pub(crate) fn forget_quit_session(&mut self) {
self.quit_session = None;
}
/// At startup, with the preference on: reopen the last session's files at the pages they
/// showed and bring its active tab forward. Files that are gone are skipped, and so are
/// `skip` (files the app is about to open anyway, e.g. from the command line) and documents
/// the Recovery dialog offers, so neither opens twice.
///
/// Only one password prompt can wait at a time: the first encrypted file asks for its
/// password and later ones stay closed (they are in the recent-files list).
pub fn reopen_last_files(&mut self, skip: &[String]) {
let last = std::mem::take(&mut self.last_session);
#[cfg(not(target_arch = "wasm32"))]
if self.reopen_last_session {
let skip: Vec<String> =
skip.iter().map(|p| absolute(p)).chain(self.recoverable.iter().filter_map(|m| m.path.as_deref().map(absolute))).collect();
let mut active = None;
let mut prompt = self.password_prompt.take();
for f in &last.files {
let open = |app: &Self| {
app.views.iter().any(|v| app.session.get(v.id).and_then(|d| d.path.as_deref()).map(absolute).as_deref() == Some(f.path.as_str()))
};
if skip.contains(&f.path) || !std::path::Path::new(&f.path).is_file() || open(self) {
continue;
}
let before = self.views.len();
self.open_path(&f.path);
if let Some(p) = self.password_prompt.take() {
prompt.get_or_insert(p);
}
if self.views.len() > before
&& let Some(view) = self.views.last_mut()
{
view.go_to_page(f.page);
if last.active.as_ref() == Some(&f.path) {
active = Some(self.views.len() - 1);
}
}
}
self.password_prompt = prompt;
if active.is_some() {
self.active = active;
}
}
#[cfg(target_arch = "wasm32")]
let _ = (last, skip);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn malformed_sessions_are_skipped_and_capped() {
let v = serde_json::json!({
"files": [{"path": 5}, {"path": ""}, "x", {"path": "/a.pdf", "page": -3}, {"path": "/b.pdf", "page": 1e300}, {"path": "/c.pdf", "page": 7}],
"active": 3,
});
let s = LastSession::from_json(&v);
let pages: Vec<(&str, usize)> = s.files.iter().map(|f| (f.path.as_str(), f.page)).collect();
assert_eq!(pages, [("/a.pdf", 0), ("/b.pdf", 0), ("/c.pdf", 7)]);
assert_eq!(s.active, None);
let many: Vec<serde_json::Value> = (0..1000).map(|i| serde_json::json!({"path": format!("/{i}.pdf")})).collect();
assert_eq!(LastSession::from_json(&serde_json::json!({"files": many})).files.len(), MAX_FILES);
assert_eq!(LastSession::from_json(&serde_json::json!("garbage")), LastSession::default());
}
}
+38
View File
@@ -72,6 +72,7 @@ pub mod forms_ui;
mod home;
mod icon_data;
pub mod icons;
pub mod last_session;
mod pageboxes;
mod palette;
mod panels;
@@ -356,6 +357,9 @@ pub struct PdfCraftApp {
pub left: LeftPanel,
pub left_open: bool,
pub right: Option<RightPanel>,
/// The user closed the Comments panel, so picking a comment tool leaves it closed until they
/// open it again (#225). Remembered across restarts.
pub comments_panel_closed: bool,
pub quick_tool: QuickTool,
/// The tool to go back to when Space, held for a temporary Hand, is released.
space_hand: Option<QuickTool>,
@@ -374,6 +378,13 @@ pub struct PdfCraftApp {
pub palette_query: String,
pub all_tools_expanded: bool,
pub recent: Vec<RecentFile>,
/// Preferences: reopen the files that were open when PdfCraft last closed (#442).
pub reopen_last_session: bool,
/// The files open when PdfCraft last closed, read from the settings for
/// [`PdfCraftApp::reopen_last_files`].
pub last_session: last_session::LastSession,
/// Quitting closes unsaved tabs one by one: what was open when the quit began.
quit_session: Option<last_session::LastSession>,
/// Folders pinned to Home, and what they held when last listed.
pub pinned: folders_ui::PinnedFolders,
pub toast: Option<(String, f64)>,
@@ -602,6 +613,7 @@ impl PdfCraftApp {
left: LeftPanel::AllTools,
left_open: true,
right: None,
comments_panel_closed: false,
quick_tool: QuickTool::Select,
space_hand: None,
comment_prefs: Default::default(),
@@ -615,6 +627,9 @@ impl PdfCraftApp {
palette_query: String::new(),
all_tools_expanded: false,
recent: Vec::new(),
reopen_last_session: false,
last_session: Default::default(),
quit_session: None,
pinned: Default::default(),
toast: None,
integrated_titlebar: false,
@@ -1032,6 +1047,7 @@ impl PdfCraftApp {
self.session.close(id);
self.active = match self.active {
_ if self.views.is_empty() => None,
Some(a) if a > index => Some(a - 1),
Some(a) if a >= self.views.len() => Some(self.views.len() - 1),
other => other,
};
@@ -1165,11 +1181,25 @@ impl PdfCraftApp {
};
}
/// Opens a right panel, or closes it with `None`, because the user chose to. Closing Comments
/// keeps comment tools from reopening it; opening it again lets them (#225).
pub fn choose_right_panel(&mut self, panel: Option<RightPanel>) {
if panel == Some(RightPanel::Comments) {
self.comments_panel_closed = false;
} else if panel.is_none() && self.right == Some(RightPanel::Comments) {
self.comments_panel_closed = true;
}
self.right = panel;
}
/// Serialize the user's persistent state (recent files, theme). Local only.
pub fn persist(&self) -> String {
let trusted: Vec<String> = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect();
serde_json::json!({
"recent": self.recent,
"reopen_last_session": self.reopen_last_session,
// Kept only while the preference is on.
"last_session": self.reopen_last_session.then(|| self.session_to_save()),
"pinned_folders": self.pinned.folders,
"theme": self.theme_preference,
"default_mode": self.default_mode,
@@ -1190,6 +1220,7 @@ impl PdfCraftApp {
"javascript": self.session.javascript(),
"actions": actions_ui::encode(&self.custom_actions),
"combine_columns": self.combine_columns.to_json(),
"comments_panel_closed": self.comments_panel_closed,
})
.to_string()
}
@@ -1204,6 +1235,10 @@ impl PdfCraftApp {
let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
self.recent = r;
}
if let Some(on) = v["reopen_last_session"].as_bool() {
self.reopen_last_session = on;
}
self.last_session = last_session::LastSession::from_json(&v["last_session"]);
self.pinned.restore(&v["pinned_folders"]);
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
self.set_theme_preference(preference);
@@ -1217,6 +1252,9 @@ impl PdfCraftApp {
if let Some(on) = v["highlight_fields"].as_bool() {
self.view_defaults.highlight_fields = on;
}
if let Some(closed) = v["comments_panel_closed"].as_bool() {
self.comments_panel_closed = closed;
}
if let Some(defaults) = v["default_zoom"].as_str().and_then(|zoom| self.view_defaults.with_zoom(zoom)) {
self.view_defaults = defaults;
}
+68 -3
View File
@@ -486,6 +486,37 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
});
ui.add_space(6.0);
let search_id = egui::Id::new(("bookmark_search", id.0));
let mut bookmark_query = ui.data(|d| d.get_temp::<String>(search_id)).unwrap_or_default();
if panel == RightPanel::Bookmarks && !info.outline.is_empty() {
ui.horizontal(|ui| {
let label = ui.label(tl!("Search"));
let previous = bookmark_query.clone();
let response =
ui.add(egui::TextEdit::singleline(&mut bookmark_query).desired_width(ui.available_width() - 32.0)).labelled_by(label.id);
response.widget_info(|| {
let mut info = egui::WidgetInfo::text_edit(ui.is_enabled(), &previous, &bookmark_query, "");
info.label = Some(tl!("Search").to_string());
info
});
if icons::button(ui, "x", 26.0, false, tl!("Clear")).clicked() {
bookmark_query.clear();
}
});
ui.data_mut(|d| d.insert_temp(search_id, bookmark_query.clone()));
ui.add_space(6.0);
}
let bookmark_query = bookmark_query.trim().to_lowercase();
let mut bookmark_matches =
if panel == RightPanel::Bookmarks { outline_matches(&info.outline, &bookmark_query) } else { std::collections::HashSet::new() };
// A new bookmark must stay nameable even when its initial title doesn't match.
if let Some((path, _)) = &bm_rename {
let mut ancestor = path.clone();
while !ancestor.is_empty() {
bookmark_matches.insert(ancestor.clone());
ancestor.pop();
}
}
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
RightPanel::Comments => {
if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) {
@@ -503,7 +534,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
editable: bm_editable,
current: view.current,
expand: bm_expand,
matches: &bookmark_matches,
searching: !bookmark_query.is_empty(),
};
if ctx.searching && bookmark_matches.is_empty() && !info.outline.is_empty() {
ui.label(tl!("No matches."));
}
for (i, item) in info.outline.iter().enumerate() {
outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx);
}
@@ -584,7 +620,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
if close {
app.right = None;
app.choose_right_panel(None);
}
if let Some(e) = panel_edit {
app.apply_edit(e);
@@ -673,6 +709,30 @@ pub enum BmAction {
Outdent(Vec<usize>),
}
/// Matching titles plus their ancestors, keeping document paths rather than filtered indexes.
fn outline_matches(items: &[OutlineItem], query: &str) -> std::collections::HashSet<Vec<usize>> {
let mut matches = std::collections::HashSet::new();
if query.is_empty() {
return matches;
}
let mut pending: Vec<_> = items.iter().enumerate().map(|(i, item)| (vec![i], item)).collect();
while let Some((path, item)) = pending.pop() {
if item.title.to_lowercase().contains(query) {
let mut ancestor = path.clone();
while !ancestor.is_empty() {
matches.insert(ancestor.clone());
ancestor.pop();
}
}
for (i, child) in item.children.iter().enumerate() {
let mut child_path = path.clone();
child_path.push(i);
pending.push((child_path, child));
}
}
matches
}
struct OutlineCtx<'a> {
nav: &'a mut Option<Nav>,
action: &'a mut Option<BmAction>,
@@ -681,6 +741,8 @@ struct OutlineCtx<'a> {
current: usize,
/// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame.
expand: Option<usize>,
matches: &'a std::collections::HashSet<Vec<usize>>,
searching: bool,
}
/// The destination page label gets its own right-aligned column in a bookmark row; drawing
@@ -709,13 +771,16 @@ fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool) -> String {
}
fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) {
if cx.searching && !cx.matches.contains(path) {
return;
}
let depth = path.len() - 1;
let indent = depth as f32 * 16.0;
let id = ui.id().with(("outline", path));
if let Some(levels) = cx.expand {
ui.data_mut(|d| d.insert_temp(id, depth < levels));
}
let mut open = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(item.open || depth == 0);
let mut open = cx.searching || ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(item.open || depth == 0);
let x_text = indent + 20.0;
if let Some((rpath, text)) = cx.rename.as_mut()
&& rpath.as_slice() == path
@@ -770,7 +835,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
if !item.children.is_empty() {
let tri = Rect::from_min_size(pos2(x0, rect.top() + 6.0), vec2(16.0, 16.0));
icons::paint(ui, tri, if open { "chevron-down" } else { "chevron-right" }, 14.0, t.text_muted);
if ui.interact(tri, id.with("tri"), Sense::click()).clicked() {
if !cx.searching && ui.interact(tri, id.with("tri"), Sense::click()).clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(id, open));
}
+12 -5
View File
@@ -17,6 +17,8 @@ pub(crate) struct SignatureDrag {
struct Layers {
image: TextureHandle,
opacity: f32,
/// The page /Rotate the PDF draws the image turned back by.
turn: i64,
renderer: RenderPool,
background: Option<TextureHandle>,
tag: Option<u64>,
@@ -37,9 +39,13 @@ impl SignatureDrag {
&& let Ok(Some(preview)) = doc.image_signature_preview(page, index)
{
let [width, height] = preview.image.size();
self.aspect_ratio = Some(width as f32 / height as f32);
let ratio = width as f32 / height as f32;
// As displayed, the image is turned by the page's rotation less the turn it is drawn back by.
let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn;
self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() });
self.layers = Some(Layers {
opacity: preview.opacity,
turn: preview.turn,
image: ctx.load_texture(
"signature-drag-image",
ColorImage::from_rgba_unmultiplied(preview.image.size(), preview.image.rgba()),
@@ -87,7 +93,8 @@ impl SignatureDrag {
self.key.is_some_and(|(_, p, i)| (p, i) == (page, index)) && self.layers.is_some()
}
/// Use the embedded image's proportions, even after an edge handle stretched its rectangle.
/// Use the embedded image's proportions, even after an edge handle stretched its rectangle: its
/// width over its height as the page is displayed (before the view's own rotation).
pub fn aspect_ratio(&self, page: usize, index: usize) -> Option<f32> {
self.key.filter(|(_, p, i)| (*p, *i) == (page, index)).and(self.aspect_ratio)
}
@@ -124,7 +131,7 @@ impl SignatureDrag {
if !(dragging || released || layers.settling) {
return;
}
let (Some(background), Some(a)) = (&layers.background, cx.get(index)) else { return };
let (Some(background), Some(a), Some(p)) = (&layers.background, cx.get(index), cx.info.pages.get(page)) else { return };
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), painter.ctx().input(|i| i.pointer.hover_pos()), pending);
let rect = cx.rect_to_user(rect);
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
@@ -132,9 +139,9 @@ impl SignatureDrag {
cx.xf.paint_user_image(
&painter,
layers.image.id(),
cx.info,
page,
p,
rect,
layers.turn,
egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8),
);
}
+10
View File
@@ -81,6 +81,16 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) {
ui.add_space(2.0);
}
/// Paint one line of `text` left-aligned and vertically centred on `pos`, cut with "…" so it is
/// at most `max_width` wide: a long file name or path stays inside its row (#425).
pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into<String>, font: egui::FontId, color: Color32, max_width: f32) {
let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0));
let galley = ui.painter().layout_job(job);
let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size());
ui.painter().galley(rect.min, galley, color);
}
/// Transient message at the bottom centre.
pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some((msg, start)) = app.toast.clone() else { return };
+2
View File
@@ -38,6 +38,7 @@ fn checking_fixing_skipping_and_reporting() {
let d = dir.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.pdf", None, FIXTURE.to_vec()).unwrap();
app.export_dir_override = Some(d.to_string_lossy().into_owned());
app
@@ -110,6 +111,7 @@ trailer << /Root 1 0 R >>
fn setting_alternate_text_figure_by_figure() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap();
app
});
+2
View File
@@ -17,6 +17,7 @@ fn run_and_create_actions() {
let d = dir.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_inline = true;
app.export_dir_override = Some(d.join("out").to_string_lossy().into_owned());
app.action_files_override = Some(vec![d.join("r.pdf").to_string_lossy().into_owned()]);
@@ -49,6 +50,7 @@ fn run_and_create_actions() {
h.get_by_label("1. Add watermark: DRAFT");
let saved = h.state().persist();
let mut fresh = PdfCraftApp::new();
fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
assert_eq!(fresh.custom_actions, h.state().custom_actions);
h.get_by_label("Start").click();
+66
View File
@@ -17,6 +17,7 @@ trailer << /Root 1 0 R >>
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "bookmarks").unwrap();
@@ -128,6 +129,71 @@ fn number_pages_dialog_labels_the_selected_pages() {
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Number pages"));
}
#[test]
fn search_finds_collapsed_bookmarks_and_keeps_original_paths() {
use pdfcraft_engine::Edit;
let mut h = harness();
for (parent, index, title, page) in
[(vec![], 0, "Unrelated", 0), (vec![], 1, "Reports", 0), (vec![1], 0, "Other chapter", 1), (vec![1], 1, "Résumé results", 2)]
{
h.state_mut().apply_edit(Edit::AddBookmark { parent, index, title: title.into(), page });
}
h.run_steps(3);
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Collapse all bookmarks").click();
h.run_steps(3);
assert!(h.query_by_label("Résumé results").is_none());
let id = h.state().views[0].id;
let before = h.state().session.save_full_bytes(id).unwrap();
h.get_by_label("Search").click();
h.run_steps(1);
h.get_by_label("Search").type_text("RÉSUMÉ");
h.run_steps(3);
h.get_by_label("Reports");
h.get_by_label("Résumé results").click();
h.run_steps(3);
assert_eq!(h.state().views[0].current, 2);
assert!(h.query_by_label("Unrelated").is_none());
assert!(h.query_by_label("Other chapter").is_none());
assert_eq!(h.state().session.save_full_bytes(id).unwrap(), before, "search doesn't edit the PDF");
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
h.render().unwrap().save(std::path::Path::new(&dir).join("filtered-bookmarks.png")).unwrap();
}
menu(&mut h, "Résumé results", "Delete");
assert_eq!(outline(&h).len(), 3);
assert!(outline(&h).iter().any(|b| b.0 == "Other chapter"));
h.get_by_label("No matches.");
add(&mut h, "New section");
assert!(outline(&h).iter().any(|b| b.0 == "New section"), "new bookmarks remain nameable while searching");
h.get_by_label("No matches.");
h.get_by_label("Clear").click();
h.run_steps(3);
h.get_by_label("Unrelated");
assert!(h.query_by_label("Other chapter").is_none(), "search did not change saved expansion state");
}
#[test]
fn bookmark_search_is_per_document() {
let mut h = harness();
add(&mut h, "First document");
h.get_by_label("Search").click();
h.run_steps(1);
h.get_by_label("Search").type_text("not found");
h.run_steps(3);
h.get_by_label("No matches.");
h.state_mut().open_bytes("second.pdf", None, PAGES.to_vec()).unwrap();
h.run_steps(3);
add(&mut h, "Second document");
h.get_by_label("Second document");
h.state_mut().active = Some(0);
h.run_steps(3);
h.get_by_label("No matches.");
h.get_by_label("Clear").click();
h.run_steps(3);
h.get_by_label("First document");
}
#[test]
fn expanding_and_collapsing_all_bookmarks() {
use pdfcraft_engine::Edit;
+2
View File
@@ -57,6 +57,7 @@ fn buttons_reset_navigate_and_print() {
let path_str = path.to_string_lossy().into_owned();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("buttons.pdf", Some(path_str), fixture()).unwrap();
app.set_option("zoom", "150").unwrap();
app
@@ -124,6 +125,7 @@ fn name_hidden(h: &Harness<'static, PdfCraftApp>) -> bool {
fn hide_actions_hide_and_show_fields() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("show-hide.pdf", None, show_hide_fixture()).unwrap();
app.set_option("zoom", "150").unwrap();
app
+4
View File
@@ -35,6 +35,7 @@ fn fixture(n: usize) -> Vec<u8> {
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
app
});
@@ -70,6 +71,7 @@ fn every_registered_command_is_implemented() {
continue;
}
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
if spec.id.starts_with("form.") || spec.id == "comment.flatten" {
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
} else {
@@ -139,6 +141,7 @@ fn every_registered_command_is_implemented() {
#[test]
fn disabled_commands_explain_themselves() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
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();
@@ -206,6 +209,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
let path = path.clone();
move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0 });
app
+65
View File
@@ -26,6 +26,7 @@ trailer << /Root 1 0 R >>
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("author", "Tester").unwrap();
@@ -115,6 +116,29 @@ fn dragging_over_text_with_the_highlighter_highlights_it() {
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add highlight"));
}
#[test]
fn a_new_highlight_opens_its_note_for_typing() {
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
assert_eq!(h.state().views[0].comments.editing, None);
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
assert_eq!(comments(&h).len(), 1);
assert_eq!(h.state().views[0].comments.editing, Some((0, 0, String::new())), "the Comments panel edits the new highlight");
// An area highlight opens its note too, replacing the empty one.
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
assert_eq!(comments(&h).len(), 2);
assert_eq!(h.state().views[0].comments.editing, Some((0, 1, String::new())));
// A note being typed is kept: the next highlight is only selected.
h.state_mut().views[0].comments.editing = Some((0, 1, "typed".into()));
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
assert_eq!(comments(&h).len(), 3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "typed".into())));
}
#[test]
fn a_selection_made_first_is_marked_when_a_tool_is_picked() {
let mut h = harness(|_| {});
@@ -539,6 +563,7 @@ fn hovering_a_comment_shows_its_author_and_text() {
);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("hover.pdf", None, pdf.into_bytes()).expect("opens");
app.set_option("left", "closed").unwrap();
// No Comments panel: the comment's text should only appear in the hover popup.
@@ -598,3 +623,43 @@ fn the_opacity_set_for_a_tool_goes_into_its_new_comments() {
h.run_steps(2);
h.get_by_label("Opacity");
}
/// #225: comment tools open the Comments panel until the user closes it; then it stays closed,
/// across restarts, until they open it again.
#[test]
fn a_closed_comments_panel_stays_closed_when_picking_comment_tools() {
use pdfcraft_ui_egui::RightPanel;
let mut h = harness(|_| {});
assert_eq!(h.state().right, None);
assert!(h.state_mut().execute("comment.highlight"));
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Comments), "Acrobat opens Comments with the tools");
h.get_by_label("Close").click();
h.run_steps(2);
assert_eq!(h.state().right, None);
for tool in ["select", "comment.underline", "comment.note", "comment.highlight"] {
if tool == "select" {
h.state_mut().quick_tool = QuickTool::Select;
} else {
assert!(h.state_mut().execute(tool));
}
h.run_steps(2);
assert_eq!(h.state().right, None, "{tool} reopened the closed Comments panel");
}
let mut again = PdfCraftApp::new();
again.restore(&h.state().persist());
assert!(again.comments_panel_closed, "the choice survives a restart");
// Opening it again from the rail lets the tools open it once more.
h.get_by_label("Comments").click();
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Comments));
assert!(!h.state().comments_panel_closed);
// What the rail and View menu call; "Bookmarks" labels two controls on screen.
h.state_mut().choose_right_panel(Some(RightPanel::Bookmarks));
h.state_mut().choose_right_panel(None);
h.run_steps(2);
assert!(!h.state().comments_panel_closed, "switching to and closing another panel isn't closing Comments");
assert!(h.state_mut().execute("comment.strikeout"));
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Comments));
}
+4
View File
@@ -13,6 +13,7 @@ fn compare_two_versions() {
let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("v1.pdf", None, v1.clone()).unwrap();
app.open_bytes("v2.pdf", None, v2.clone()).unwrap();
app
@@ -49,6 +50,7 @@ fn compare_panel_screenshot() {
let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("v1.pdf", None, v1.clone()).unwrap();
app.open_bytes("v2.pdf", None, v2.clone()).unwrap();
app
@@ -68,6 +70,7 @@ fn pdfa_dialog_verifies_and_converts() {
let doc = Session::new().create_from_text("t", "Keep forever").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("keep.pdf", None, doc.clone()).unwrap();
app
});
@@ -93,6 +96,7 @@ fn export_to_word_html_and_rtf() {
let dir = std::env::temp_dir().join(format!("pdfcraft-office-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("e.pdf", None, doc).unwrap();
for ext in ["docx", "html", "rtf"] {
let out = dir.join(format!("e.{ext}"));
+1
View File
@@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("zoom", "150").unwrap();
app
+30
View File
@@ -43,6 +43,7 @@ fn harness_pages(pages: usize) -> (Harness<'static, PdfCraftApp>, ControlClient)
let s = slot.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
*s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app.open_bytes("doc.pdf", None, fixture(pages)).unwrap();
app
@@ -68,6 +69,35 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa
call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}"))
}
#[test]
fn bookmark_titles_can_be_searched_over_control() {
let (mut h, c) = harness();
for (index, title, page) in [(0, "Background", 0), (1, "Target chapter", 3)] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page });
}
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "bookmarks" }));
h.run_steps(3);
let widgets = ok(&mut h, &c, "ui.inspect", json!({ "query": "Search", "role": "TextInput" }));
let rect = &widgets["widgets"][0]["rect"];
let x = (rect[0].as_f64().unwrap() + rect[2].as_f64().unwrap()) / 2.0;
let y = (rect[1].as_f64().unwrap() + rect[3].as_f64().unwrap()) / 2.0;
ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }));
ok(&mut h, &c, "ui.type", json!({ "text": "target" }));
h.get_by_label("Target chapter");
assert!(h.query_by_label("Background").is_none());
ok(&mut h, &c, "ui.click", json!({ "label": "Target chapter" }));
assert_eq!(h.state().views[0].current, 3);
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
h.render().unwrap().save(format!("{dir}/control-filtered-bookmarks.png")).unwrap();
}
ok(&mut h, &c, "ui.click", json!({ "label": "Clear" }));
h.get_by_label("Background");
h.get_by_label("Target chapter");
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
h.render().unwrap().save(format!("{dir}/control-cleared-bookmarks.png")).unwrap();
}
}
#[test]
fn theme_commands_and_options_report_preference_and_effective_colours() {
let (mut h, c) = harness();
+5
View File
@@ -54,6 +54,7 @@ fn image_import_dialog_chooses_dpi_and_cancels() {
#[test]
fn opening_images_and_text_converts_them_to_new_pdfs() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("photo.png", Some("/tmp/photo.png".into()), png()).unwrap();
app.open_bytes("notes.txt", None, b"first line\nsecond line".to_vec()).unwrap();
app.create_from_images(vec![("a.png".into(), png()), ("b.png".into(), png())]);
@@ -77,6 +78,7 @@ fn reduce_file_size_writes_a_compact_copy() {
let out2 = out.clone();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap();
app.save_override = Some(out2.to_string_lossy().into_owned());
app
@@ -100,6 +102,7 @@ fn reduce_file_size_writes_a_compact_copy() {
fn clipboard_images_and_text_become_new_pdfs() {
use pdfcraft_ui_egui::Clip;
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.create_from_clip(Clip::Image { width: 40, height: 20, rgba: [10u8, 20, 30, 255].repeat(40 * 20) }).unwrap();
app.create_from_clip(Clip::Text("Pasted\nlines".into())).unwrap();
let docs = app.session.docs();
@@ -121,6 +124,7 @@ fn the_pdf_optimizer_dialog_saves_an_optimized_copy() {
let out2 = out.clone();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap();
app.save_override = Some(out2.to_string_lossy().into_owned());
app
@@ -211,6 +215,7 @@ fn multiple_files_open_as_one_document_in_the_page_grid() {
#[test]
fn multiple_files_that_cannot_be_converted_open_nothing() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.use_files(pdfcraft_ui_egui::FilePurpose::CreateMultiple, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]);
assert!(app.views.is_empty());
assert!(app.toast.is_some());
+1
View File
@@ -44,6 +44,7 @@ fn fixture() -> Vec<u8> {
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("links.pdf", None, fixture()).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
+181
View File
@@ -37,6 +37,7 @@ fn fixture(n: usize) -> Vec<u8> {
fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens");
setup(&mut app);
app
@@ -387,6 +388,119 @@ fn quitting_with_unsaved_changes_asks_for_each_document() {
assert!(h.state().close_request.is_none());
}
/// PDFs written to a fresh temp folder, removed when the test ends.
struct TempPdfs(std::path::PathBuf);
impl TempPdfs {
fn new(test: &str) -> Self {
let dir = std::env::temp_dir().join(format!("pdfcraft-session-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self, name: &str, pages: usize) -> String {
let path = self.0.join(name);
std::fs::write(&path, fixture(pages)).unwrap();
path.to_string_lossy().into_owned()
}
}
impl Drop for TempPdfs {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The app with Preferences ▸ Reopen the files that were open when PdfCraft last closed on.
fn session_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.reopen_last_session = true;
setup(&mut app);
app
});
h.run_steps(4);
h
}
fn open_paths(app: &PdfCraftApp) -> Vec<String> {
app.views.iter().map(|v| app.session.get(v.id).and_then(|d| d.path.clone()).unwrap_or_default()).collect()
}
#[test]
fn the_last_session_reopens_its_files_pages_and_active_tab() {
let dir = TempPdfs::new("reopen");
let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 5), dir.file("c.pdf", 2)];
let opened = files.clone();
let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f)));
h.state_mut().views[1].go_to_page(3);
h.state_mut().active = Some(1);
h.run_steps(4);
assert_eq!(h.state().views[1].current, 3, "b.pdf shows page 4");
let settings = h.state().persist();
let mut h = session_harness(move |app| {
app.restore(&settings);
app.reopen_last_files(&[]);
});
h.run_steps(4);
let app = h.state();
assert_eq!(open_paths(app), files, "the same files, in the same order");
assert_eq!(app.active, Some(1), "b.pdf is in front again");
assert_eq!(app.views[1].current, 3, "at the page it showed");
}
#[test]
fn quitting_with_unsaved_changes_still_remembers_every_file() {
let dir = TempPdfs::new("quit");
let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 1)];
let opened = files.clone();
let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f)));
for tab in 0..2 {
h.state_mut().active = Some(tab);
h.state_mut().views[tab].select_pages(&[0]);
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
}
h.state_mut().active = Some(1);
h.state_mut().close_request = Some(CloseRequest::Quit);
h.run_steps(3);
// Each unsaved tab closes as it is answered, before PdfCraft quits.
for _ in 0..2 {
h.get_by_label("Don't save").click();
h.run_steps(3);
}
assert!(h.state().views.is_empty());
let mut app = PdfCraftApp::new();
app.restore(&h.state().persist());
app.reopen_last_files(&[]);
assert_eq!(open_paths(&app), files, "both files come back");
assert_eq!(app.active, Some(1), "with b.pdf in front, as when the quit began");
}
#[test]
fn the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files() {
let dir = TempPdfs::new("skip");
let (a, b) = (dir.file("a.pdf", 1), dir.file("b.pdf", 1));
let mut app = PdfCraftApp::new();
app.open_path(&a);
app.open_path(&b);
let off: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
assert!(off["last_session"].is_null(), "nothing is kept while the preference is off");
app.reopen_last_session = true;
let settings = app.persist();
let mut later = PdfCraftApp::new();
later.restore(&settings);
later.reopen_last_session = false;
later.reopen_last_files(&[]);
assert!(later.views.is_empty(), "turned off again: nothing reopens");
// b.pdf is gone, and a.pdf is about to open from the command line.
std::fs::remove_file(&b).unwrap();
let mut later = PdfCraftApp::new();
later.restore(&settings);
later.reopen_last_files(&[a]);
assert!(later.views.is_empty(), "neither opens from the session");
}
#[test]
fn save_prompt_stays_inside_the_screen_for_a_long_filename() {
// Issue #161: an unwrapped title carrying a long filename widened the centered modal past
@@ -395,6 +509,7 @@ fn save_prompt_stays_inside_the_screen_for_a_long_filename() {
Vliek, Ed Sutherland, -- 5, 2024 -- McGraw-Hill Education (UK) Ltd -- isbn13 97815268.pdf";
let mut h = Harness::builder().with_size(egui::vec2(1365.0, 719.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes(name, None, fixture(1)).expect("fixture opens");
app.close_request = Some(CloseRequest::Tab(app.views[0].id));
app
@@ -429,6 +544,7 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() {
let start: String = name.chars().take(10).collect();
let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes(&name, None, fixture(1)).expect("fixture opens");
app.close_request = Some(CloseRequest::Tab(app.views[0].id));
app
@@ -818,6 +934,7 @@ fn columns_resize_and_the_layout_is_kept_in_the_settings() {
assert!(widths[size_index] > 120.0, "{widths:?}");
let saved = h.state().persist();
let mut fresh = PdfCraftApp::new();
fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
assert_eq!(fresh.combine_columns, h.state().combine_columns);
// Malformed settings give the default layout.
@@ -1123,6 +1240,7 @@ fn save_pages_writes_what_the_grid_shows() {
h.run_steps(4);
assert!(!dirty(&h));
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("saved.pdf", None, std::fs::read(&path).unwrap()).unwrap();
assert_eq!(texts_of(&app, 0), ["Page 1", "Page 3"]);
}
@@ -1194,6 +1312,7 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p
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 = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("secret.pdf", None, protected("pw", "owner", -1)).unwrap();
app
});
@@ -1218,6 +1337,7 @@ fn password_prompt_opens_and_security_tab_reports_the_details() {
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 = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); // opens without a password
app.set_option("organize", "on").unwrap();
app
@@ -1240,6 +1360,7 @@ fn restricted_documents_show_a_notice_and_block_page_changes() {
#[test]
fn replace_pages_dialog_swaps_page_content() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
app.views[0].select_pages(&[1]);
app.start_replace("other.pdf".into(), fixture(5));
@@ -1365,6 +1486,7 @@ fn source_font_fixture() -> Vec<u8> {
fn open_source_font_fixture() -> Harness<'static, PdfCraftApp> {
Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("fonts.pdf", None, source_font_fixture()).expect("font fixture opens");
app
})
@@ -1465,6 +1587,7 @@ fn double_drawn() -> Vec<u8> {
fn editing_a_double_drawn_line_replaces_every_copy() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("bold.pdf", None, double_drawn()).expect("opens");
app
});
@@ -1497,6 +1620,7 @@ fn editing_existing_images_on_the_page() {
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.png", None, png.clone()).expect("opens");
app
});
@@ -1618,10 +1742,67 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() {
assert!(h.state().views[0].line_editor.is_none());
}
/// A quick flick: press at `from`, and the next frame already finds the pointer at `to`.
fn flick(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
h.hover_at(to);
h.run_steps(1);
h.drop_at(to);
h.run_steps(4);
}
#[test]
fn a_quick_flick_on_either_edge_rewraps_the_paragraph() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let lines = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_lines(0)
};
let block = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].clone()
};
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k);
let near = |a: f64, b: f64| (a - b).abs() < 1.5;
// Grab edges near the box's top: a rewrapped box reaches the window's bottom, where the
// tool's toast sits over the page and takes the press.
let grip_y = |b: &pdfcraft_engine::TextBlock| b.rect[3] - 6.0;
// Narrow "Page 1" from its right edge to about 45 pt: it rewraps onto two lines.
let b = block(&h);
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0);
flick(&mut h, edge, edge - egui::vec2((b.rect[2] - b.rect[0] - 45.0) as f32 * k, 0.0));
let text: Vec<String> = lines(&h).iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page", "1"], "narrowed from the right edge");
// Flick the right edge outward, past the box and its handle: back onto one line.
let b = block(&h);
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0);
flick(&mut h, edge, edge + egui::vec2(80.0 * k, 0.0));
let text: Vec<String> = lines(&h).iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page 1"], "widened by a fast drag that left the handle at once");
// Flick the left edge inward: it rewraps and its left side follows the pointer.
let b = block(&h);
let edge = screen(b.rect[0], grip_y(&b)) - egui::vec2(2.0, 0.0);
let inward = (b.rect[2] - b.rect[0] - 45.0) as f32;
flick(&mut h, edge, edge + egui::vec2(inward * k, 0.0));
let after = lines(&h);
let text: Vec<String> = after.iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page", "1"], "narrowed from the left edge");
assert!(near(after[0].rect[0], b.rect[0] + inward as f64), "{:?} → {:?}", b.rect, after[0].rect);
assert!(h.state().views[0].line_editor.is_none());
}
/// The Pages panel, in a window tall enough to show every thumbnail of a short fixture.
fn pages_panel(pages: usize) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens");
app.set_option("panel", "pages").unwrap();
app
+1
View File
@@ -20,6 +20,7 @@ fn export_dialogs_write_images_and_text() {
let d = dir.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
app.export_dir_override = Some(d.to_string_lossy().into_owned());
app
+80 -24
View File
@@ -21,6 +21,7 @@ fn harness_bytes(fixture: &[u8]) -> Harness<'static, PdfCraftApp> {
let fixture = fixture.to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, fixture.clone()).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
@@ -44,6 +45,21 @@ fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph)
}
/// The screen point `dx` points right and `dy` points up, as displayed, from the user-space point
/// (`x`, `y`): unlike `at`, offsets don't turn with the page's /Rotate.
fn shown(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32, dx: f32, dy: f32) -> Pos2 {
let s = h.state();
let page = &s.session.get(s.views[0].id).unwrap().info.pages[0];
let xf = pdfcraft_ui_egui::canvas::PageXform {
rect: s.views[0].page_screen_rect(0).expect("on screen"),
rot: s.views[0].rotation,
pw: page.width,
ph: page.height,
};
let p = page.user_to_view(x, y);
xf.norm_to_screen((p[0] + dx) / xf.pw, (p[1] - dy) / xf.ph)
}
fn click(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
let p = at(h, x, y);
h.hover_at(p);
@@ -129,6 +145,7 @@ fn signing_draws_a_signature_once_and_places_it() {
// The signature is remembered (persisted with the app's settings).
let saved = h.state().persist();
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(&saved);
assert!(again.signature.is_some());
}
@@ -160,6 +177,7 @@ fn typed_signatures_and_initials() {
// Both are remembered.
let saved = h.state().persist();
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(&saved);
assert_eq!(again.signature, h.state().signature);
assert_eq!(again.initials, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into())));
@@ -214,6 +232,7 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
click(&mut h, 40.0, 100.0);
assert_eq!(items(&h).len(), 3);
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(&h.state().persist());
assert_eq!(again.signature, Some(SavedSig::Typed("Grace Hopper".into())));
assert_eq!(again.initials, Some(SavedSig::Typed("GH".into())));
@@ -269,6 +288,7 @@ fn saved_signature_cards_remove_and_add_without_changing_the_document() {
assert_eq!(h.state().signature, None);
assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into())));
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(&h.state().persist());
assert_eq!(again.signature, None, "removal survives restart");
h.get_by_label("Add signature").click();
@@ -292,7 +312,8 @@ fn saved_signature_cards_remove_and_add_without_changing_the_document() {
fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() {
let text = "Alexandria Catherine Elizabeth Montgomery-Wellington";
let sig = SavedSig::Typed(text.into());
let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, [40.0, 200.0], &sig, false, "").unwrap() else {
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, &page, [40.0, 200.0], &sig, false, "").unwrap() else {
panic!("expected annotation");
};
let pdfcraft_engine::Shape::TypedSignature { rect, contours } = a.shape else { panic!("expected typed signature") };
@@ -314,9 +335,15 @@ fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() {
}
fn signature_file(test: &str) -> std::path::PathBuf {
signature_file_with_mark(test, 10..20)
}
/// A 120 x 40 signature: a stroke across the middle and a block above it at the columns `mark`
/// (the rest is transparent), so a turned or flipped picture shows.
fn signature_file_with_mark(test: &str, mark: std::ops::Range<u32>) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("pdfcraft-signature-{test}-{}.png", std::process::id()));
let image = image::RgbaImage::from_fn(120, 40, |x, y| {
if (10..110).contains(&x) && (15..25).contains(&y) || (10..20).contains(&x) && (5..15).contains(&y) {
if (10..110).contains(&x) && (15..25).contains(&y) || mark.contains(&x) && (5..15).contains(&y) {
image::Rgba([20, 30, 40, 255])
} else {
image::Rgba([0, 0, 0, 0])
@@ -366,6 +393,7 @@ fn image_signatures_and_initials_can_be_imported_placed_and_remembered() {
let settings = state.persist();
assert!(!settings.contains(&path.to_string_lossy().to_string()), "only the image is saved, never its source path");
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(&settings);
assert_eq!(again.signature, state.signature);
assert_eq!(again.initials, state.initials);
@@ -398,7 +426,8 @@ fn image_signatures_and_initials_can_be_imported_placed_and_remembered() {
#[test]
fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() {
let path = signature_file("pointer");
// The block above the stroke is at its right end, away from the pointer's own cursor.
let path = signature_file_with_mark("pointer", 100..110);
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
let mut h = harness();
// A link under the placement point must not replace the image with a hand cursor.
@@ -410,7 +439,8 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation()
});
h.state_mut().signature = Some(SavedSig::Image(image));
h.state_mut().execute("sign.fill.signature");
// Exercise document rotation and view rotation separately and together.
// Exercise document rotation and view rotation separately and together. The image is upright
// as displayed, whatever the page's /Rotate; the view rotation turns the whole screen.
let mut previous_document_rotation = 0;
for (document_rotation, view_rotation, zoom) in [(0, 0, "150"), (90, 0, "100"), (0, 90, "100"), (90, 90, "75"), (0, 180, "75"), (0, 270, "100")] {
let delta = document_rotation - previous_document_rotation;
@@ -427,20 +457,23 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation()
h.hover_at(at(&h, 40.0, y));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the image replaces the crosshair");
let ink = at(&h, 90.0, y);
let margin = at(&h, 42.0, y);
let upper_stroke = at(&h, 52.0, y + 8.0);
let lower_margin = at(&h, 52.0, y - 8.0);
// The picture is 96 x 32 pt with its left edge at the pointer: the stroke spans 8..88
// and the block above it 80..88. egui_kittest paints a mouse cursor, a 16 px triangle
// down and right of the pointer, over the render, so only probe far from the pointer.
let ink = shown(&h, 40.0, y, 50.0, 0.0);
let margin = shown(&h, 40.0, y, 92.0, 0.0);
let upper_stroke = shown(&h, 40.0, y, 84.0, 8.0);
let lower_margin = shown(&h, 40.0, y, 84.0, -8.0);
let pixels = h.render().unwrap();
assert!(pixels.get_pixel(ink.x as u32, ink.y as u32).0[..3].iter().all(|v| *v < 60), "ink follows the pointer at the placed size");
assert!(pixels.get_pixel(margin.x as u32, margin.y as u32).0[..3].iter().all(|v| *v > 240), "alpha exposes the page");
assert!(
pixels.get_pixel(upper_stroke.x as u32, upper_stroke.y as u32).0[..3].iter().all(|v| *v < 60),
"the asymmetric image rotates with the page"
"the asymmetric image stays upright as displayed"
);
assert!(pixels.get_pixel(lower_margin.x as u32, lower_margin.y as u32).0[..3].iter().all(|v| *v > 240), "the image isn't flipped");
if y == 150.0 {
let old = at(&h, 90.0, 250.0);
let old = shown(&h, 40.0, 250.0, 50.0, 0.0);
assert!(pixels.get_pixel(old.x as u32, old.y as u32).0[..3].iter().all(|v| *v > 240), "moving the pointer removes the old preview");
}
if document_rotation == 0
@@ -659,31 +692,53 @@ fn embedded_image_signature_live_gestures_follow_document_and_view_rotation() {
h.state_mut().signature = Some(SavedSig::Image(image.clone()));
h.state_mut().execute("sign.fill.signature");
h.run_steps(3);
click(&mut h, 40.0, 250.0);
rendered_ink(&mut h, 90.0, 250.0);
// Points as displayed (from the shown page's bottom-left, y up). The signature is upright
// as displayed, so the same points apply whatever the page's /Rotate.
let page = h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].clone();
let u = |x: f32, y: f32| page.view_to_user(x, page.height - y);
let [x, y] = u(40.0, 250.0);
click(&mut h, x, y);
let [x, y] = u(90.0, 250.0);
rendered_ink(&mut h, x, y);
// Future placements may use a different signature: drag the PDF's embedded image.
h.state_mut().signature = None;
h.drag_at(at(&h, 136.0, 234.0));
let [x, y] = u(136.0, 234.0);
h.drag_at(at(&h, x, y));
h.run_steps(1);
let end = at(&h, 184.0, 228.0);
let [x, y] = u(184.0, 228.0);
let end = at(&h, x, y);
h.hover_at(end);
rendered_ink(&mut h, 160.0, 242.0);
let [x, y] = u(160.0, 242.0);
rendered_ink(&mut h, x, y);
let pixels = h.render().unwrap();
assert!(pixel_at(&h, &pixels, 58.0, 254.0)[..3].iter().all(|v| *v < 60), "asymmetric ink rotates during resize");
assert!(pixel_at(&h, &pixels, 58.0, 230.0)[..3].iter().all(|v| *v > 240), "alpha isn't flipped");
let [x, y] = u(58.0, 254.0);
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "asymmetric ink stays upright during resize");
let [x, y] = u(58.0, 230.0);
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "alpha isn't flipped");
h.drop_at(end);
h.run_steps(3);
h.drag_at(at(&h, 112.0, 242.0));
// The corner keeps the image's 3:1 as displayed: 144 x 48 from the top-left corner.
let ([x0, y0], [x1, y1]) = (u(40.0, 218.0), u(184.0, 266.0));
let rect = h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect;
let want = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)];
assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rect:?} != {want:?}");
let [x, y] = u(112.0, 242.0);
h.drag_at(at(&h, x, y));
h.run_steps(1);
h.hover_at(at(&h, 132.0, 142.0));
let [x, y] = u(132.0, 142.0);
let target = at(&h, x, y);
h.hover_at(target);
h.run_steps(2);
let pixels = h.render().unwrap();
assert!(pixel_at(&h, &pixels, 132.0, 142.0)[..3].iter().all(|v| *v < 60), "image moves before release");
assert!(pixel_at(&h, &pixels, 160.0, 242.0)[..3].iter().all(|v| *v > 240), "old ink is removed during movement");
assert!(!has_blue_near(&pixels, at(&h, 204.0, 166.0)));
h.drop_at(at(&h, 132.0, 142.0));
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "image moves before release");
let [x, y] = u(160.0, 242.0);
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "old ink is removed during movement");
let [x, y] = u(204.0, 166.0);
let corner = at(&h, x, y);
assert!(!has_blue_near(&pixels, corner));
h.drop_at(target);
h.run_steps(2);
assert!(has_blue_near(&h.render().unwrap(), at(&h, 204.0, 166.0)));
assert!(has_blue_near(&h.render().unwrap(), corner));
}
std::fs::remove_file(path).unwrap();
}
@@ -800,6 +855,7 @@ fn image_import_cancel_clear_and_errors_preserve_saved_signatures() {
assert!(h.state().signature_draft.image.is_none());
// Malformed settings are ignored while legacy typed/drawn values still restore.
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(r#"{"signature_text":"Ada","signature_image":{"Image":"bm90IGFuIGltYWdl"},"initials":{"Image":"%%%"}}"#);
assert_eq!(again.signature, Some(SavedSig::Typed("Ada".into())));
assert!(again.initials.is_none());
+30
View File
@@ -51,6 +51,7 @@ impl Drop for TempFolder {
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
// List folders on the frame that asks, not on a worker thread.
app.run_inline = true;
setup(&mut app);
@@ -144,8 +145,10 @@ fn pinned_folders_survive_a_restart() {
let dir = TempFolder::new("restart");
let folder = dir.path();
let mut a = PdfCraftApp::new();
a.set_option("language", "en").unwrap();
assert!(a.pin_folder(&folder));
let mut b = PdfCraftApp::new();
b.set_option("language", "en").unwrap();
b.restore(&a.persist());
assert_eq!(b.pinned.folders, [folder]);
b.restore("{\"pinned_folders\": [5, \"relative/path\"]}");
@@ -194,9 +197,36 @@ fn show_more_reveals_the_older_pdfs() {
h.get_by_label("Show less");
}
#[test]
fn a_long_pdf_name_is_cut_before_its_date_and_size() {
let dir = TempFolder::new("long");
// Short enough for Windows' 260-character paths under the temp folder.
let name = format!("{}.pdf", "Signed contract with a long name ".repeat(4));
dir.file(&name, FIXTURE, Duration::from_secs(10));
let folder = dir.path();
let mut h = harness(move |app| {
app.pin_folder(&folder);
});
h.run_steps(2);
let text: Vec<(String, bool, egui::Rect)> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.galley.elided, t.visual_bounding_rect())),
_ => None,
})
.collect();
let (_, cut, rect) = text.iter().find(|(s, ..)| *s == name).expect("the PDF is listed");
let detail = text.iter().find(|(s, _, r)| s.contains('·') && r.y_range().contains(rect.center().y)).expect("its row shows the date and size").2;
assert!(*cut, "the name is cut short");
assert!(rect.right() < detail.left(), "the name ends before the date and size");
}
#[test]
fn pinning_is_limited_and_never_duplicates() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert!(!app.pin_folder("relative/scans"), "a relative path is refused");
let root = if cfg!(windows) { "C:\\" } else { "/" };
let folders: Vec<String> = (0..MAX_PINNED).map(|i| Path::new(root).join(format!("pinned-{i}")).to_string_lossy().into_owned()).collect();
+2 -1
View File
@@ -162,7 +162,7 @@ fn ui_fonts_work_without_craft_fonts() {
theme::install_fonts(&ctx);
}
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, Spanish, French, Russian) are drawn entirely by the app's own faces
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, Spanish, French, Russian, Bulgarian) are drawn entirely by the app's own faces
/// (Inter, JetBrains Mono): no letter falls through to egui's defaults or a CJK fallback. (egui's
/// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.)
#[test]
@@ -175,6 +175,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
("es", include_str!("../src/i18n/es.tsv")),
("fr", include_str!("../src/i18n/fr.tsv")),
("ru", include_str!("../src/i18n/ru.tsv")),
("bg", include_str!("../src/i18n/bg.tsv")),
] {
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜ¿¡«»…");
for line in catalog.lines().filter(|l| !l.starts_with('#')) {
+78
View File
@@ -6,6 +6,7 @@ use pdfcraft_ui_egui::PdfCraftApp;
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("left", "closed").unwrap();
// The whole 300×400 pt page on screen.
@@ -148,6 +149,7 @@ fn date_fields_offer_a_calendar() {
/// The value of `name` in a saved PDF, read back by opening it in a fresh app.
fn saved_value(path: &std::path::Path, name: &str) -> Vec<String> {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("saved.pdf", None, std::fs::read(path).unwrap()).unwrap();
app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
@@ -310,6 +312,7 @@ fn a_rejected_value_keeps_the_typing_and_stops_the_save() {
let out = dir.join("saved.pdf");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("order.pdf", None, validated_form()).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
@@ -356,6 +359,7 @@ fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() {
let out = dir.join("saved.pdf");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("order.pdf", None, validated_form()).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
@@ -397,3 +401,77 @@ fn flattening_while_typing_flattens_the_typed_text() {
.unwrap_or_default();
assert!(text.contains("Flat Ada"), "into the page: {text:?}");
}
#[cfg(not(target_arch = "wasm32"))]
mod revert_tests {
use super::*;
use egui_kittest::kittest::Queryable;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
struct RevertDir(PathBuf);
impl RevertDir {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let path = std::env::temp_dir().join(format!("pdfcraft-revert-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("creating Revert test directory: {error}"),
}
}
panic!("no unused Revert test directory after 128 attempts");
}
}
impl Drop for RevertDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn reverting_rejected_input_saves_the_original_field_and_appearance() {
let dir = RevertDir::new();
let path = dir.0.join("saved.pdf");
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue {
name: "name".into(),
value: pdfcraft_engine::FieldValue::Text("Saved original".into()),
}));
h.state_mut().save_override = Some(path.to_string_lossy().into_owned());
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace));
let saved = std::fs::read(&path).unwrap();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
name: "name".into(),
props: Box::new(pdfcraft_engine::FieldProps {
validate: Some(pdfcraft_engine::form_scripts::Validate::Range { min: Some(0.0), max: Some(10.0) }),
..Default::default()
}),
}));
h.run_steps(3);
click_field(&mut h, "name", 0);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("99".into()));
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
h.run_steps(4);
assert_eq!(h.state().views[0].forms.focus.as_ref().unwrap().text, "99");
assert_eq!(std::fs::read(&path).unwrap(), saved, "rejection leaves the saved file untouched");
assert!(h.state_mut().execute("file.revert"));
h.run_steps(2);
h.get_all_by_label("Revert").last().expect("confirmation button").click();
h.run_steps(4);
assert!(h.state().views[0].forms.focus.is_none() && h.state().views[0].pending_edit.is_none());
assert_eq!(h.state().first_dirty(), None, "Revert discards rejected typing too");
assert_eq!(value(&h, "name"), ["Saved original"]);
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace));
assert_eq!(saved_value(&path, "name"), ["Saved original"]);
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes, saved, "later Save cannot resurrect the discarded draft");
let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(bytes), Default::default());
let output = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, ..Default::default() });
assert!(output.error.is_none(), "saved appearance is readable: {:?}", output.error);
assert!(output.text.unwrap().plain_text().contains("Saved original"), "saved AP still draws the original value");
}
}
+2
View File
@@ -21,6 +21,7 @@ trailer << /Root 1 0 R >>
fn the_identity_name_signs_new_comments_and_is_remembered() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, PDF.to_vec()).expect("opens");
app.set_option("author", "Tester").unwrap();
app.set_option("dialog", "preferences").unwrap();
@@ -62,6 +63,7 @@ fn the_identity_name_signs_new_comments_and_is_remembered() {
assert_eq!(author(&h).as_deref(), Some("Grace Hopper"));
// It survives a restart; an empty or missing name keeps the default, and a huge one is cut.
let mut restored = PdfCraftApp::new();
restored.set_option("language", "en").unwrap();
restored.restore(&h.state().persist());
assert_eq!(restored.comment_prefs.author, "Grace Hopper");
restored.restore(r#"{"author": " "}"#);
+4
View File
@@ -33,6 +33,7 @@ fn form() -> Vec<u8> {
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, form()).unwrap();
app
});
@@ -98,6 +99,7 @@ fn document_scripts_and_preferences() {
assert!(!h.state().session.javascript());
let saved = h.state().persist();
let mut fresh = PdfCraftApp::new();
fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
assert!(!fresh.session.javascript(), "the preference is remembered");
}
@@ -108,6 +110,7 @@ fn merge_data_files_into_a_spreadsheet() {
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("report.csv");
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.save_override = Some(out.to_string_lossy().into_owned());
app.merge_data_files(vec![("form.pdf".into(), form())]);
assert_eq!(std::fs::read_to_string(&out).unwrap(), "greeting\n\n");
@@ -121,6 +124,7 @@ fn prepare_a_form_detects_fields_on_a_paper_form() {
let paper = pdfcraft_engine::Session::new().create_from_text("t", "Name: ____________________\n\nPhone: ____________________").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("paper.pdf", None, paper.clone()).unwrap();
app
});
+1
View File
@@ -38,6 +38,7 @@ fn fixture() -> Vec<u8> {
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("layers.pdf", None, fixture()).unwrap();
app.set_option("zoom", "150").unwrap();
setup(&mut app);
+1
View File
@@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
setup(&mut app);
app
});
+1
View File
@@ -17,6 +17,7 @@ trailer << /Root 1 0 R >>
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
app
});
+2
View File
@@ -19,6 +19,7 @@ fn scan() -> Vec<u8> {
fn recognize_text_dialog_adds_searchable_text() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
@@ -52,6 +53,7 @@ fn recognize_text_in_multiple_files_writes_searchable_copies() {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_inline = true;
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
app.use_files(pdfcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]);
+1
View File
@@ -15,6 +15,7 @@ trailer << /Root 1 0 R >>
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
+1
View File
@@ -10,6 +10,7 @@ fn harness() -> Harness<'static, PdfCraftApp> {
// 60 fps steps, so two clicks fall inside egui's double-click window.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("zoom", "150").unwrap();
app
+2
View File
@@ -100,6 +100,7 @@ fn scrolling_a_500_page_document_stays_within_the_frame_budget() {
let t0 = std::time::Instant::now();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("big.pdf", None, bytes).expect("opens");
app
});
@@ -124,6 +125,7 @@ fn panels_with_hundreds_of_items_stay_within_the_frame_budget() {
let b = bytes.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("big.pdf", None, b).expect("opens");
app.set_option("panel", panel).unwrap();
app
+1
View File
@@ -45,6 +45,7 @@ fn harness(docs: &[usize]) -> Harness<'static, PdfCraftApp> {
let docs = docs.to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
for (i, n) in docs.iter().enumerate() {
app.open_bytes(&format!("doc{i}.pdf"), None, fixture(*n)).unwrap();
}
+1
View File
@@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("zoom", "150").unwrap();
app
+1
View File
@@ -7,6 +7,7 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app
});
+1
View File
@@ -14,6 +14,7 @@ trailer << /Root 1 0 R >>
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
app
});
+5
View File
@@ -46,6 +46,7 @@ fn files_in(s: &RecoveryStore) -> usize {
/// 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 = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.enable_recovery(s.clone());
match password {
Some(pw) => {
@@ -62,6 +63,7 @@ fn crashed_session(s: &RecoveryStore, bytes: Vec<u8>, password: Option<&str>, pa
fn harness(s: RecoveryStore) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.enable_recovery(s);
app
});
@@ -103,6 +105,7 @@ fn discarding_removes_the_autosaves() {
fn saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents() {
let s = store("save");
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.enable_recovery(s.clone());
app.open_bytes("a.pdf", None, fixture(2)).unwrap();
app.autosave_now();
@@ -130,6 +133,7 @@ fn quitting_cleanly_leaves_nothing_behind() {
let s = s.clone();
move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.enable_recovery(s);
app.open_bytes("q.pdf", None, fixture(2)).unwrap();
app
@@ -206,6 +210,7 @@ fn control_click_effects_are_visible_when_the_reply_arrives() {
let (slot2, s2) = (slot.clone(), s.clone());
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
*slot2.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app.enable_recovery(s2);
app
+1
View File
@@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("zoom", "150").unwrap();
app
+4
View File
@@ -30,6 +30,7 @@ fn dir() -> std::path::PathBuf {
fn harness(dir: std::path::PathBuf) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("contract.pdf", None, FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
@@ -155,6 +156,7 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() {
// Trusted certificates and the ID list persist.
let saved = s.persist();
let mut fresh = PdfCraftApp::new();
fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
assert_eq!((fresh.digital_ids.len(), fresh.session.trusted_certificates().len()), (1, 1));
}
@@ -248,6 +250,7 @@ fn clicking_an_empty_signature_field_signs_it() {
#[test]
fn os_store_identities_are_not_persisted() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
for path in ["windows:Store signer", "keychain:Store signer", "file-id.p12"] {
app.digital_ids.push(pdfcraft_ui_egui::DigitalIdEntry {
path: path.into(),
@@ -262,6 +265,7 @@ fn os_store_identities_are_not_persisted() {
assert_eq!(settings["digital_ids"].as_array().unwrap().len(), 1);
assert_eq!(settings["digital_ids"][0]["path"], "file-id.p12");
let mut restored = PdfCraftApp::new();
restored.set_option("language", "en").unwrap();
restored.restore(&saved);
assert_eq!(restored.digital_ids.len(), 1);
assert_eq!(restored.digital_ids[0].path, "file-id.p12");
+3
View File
@@ -8,6 +8,7 @@ use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
fn choosing_and_placing_stamps() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("zoom", "150").unwrap();
app
@@ -47,6 +48,7 @@ fn choosing_and_placing_stamps() {
fn creating_placing_and_keeping_custom_stamps() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("zoom", "150").unwrap();
app
@@ -88,6 +90,7 @@ fn creating_placing_and_keeping_custom_stamps() {
// The library is kept with the app's settings.
let saved = s.persist();
let mut again = PdfCraftApp::new();
again.set_option("language", "en").unwrap();
again.restore(&saved);
assert_eq!(again.custom_stamps.len(), 1);
assert_eq!((again.custom_stamps[0].category.as_str(), again.custom_stamps[0].name.as_str()), ("Company", "logo"));
+1
View File
@@ -29,6 +29,7 @@ trailer << /Root 1 0 R >>
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("lines.pdf", None, two_lines()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("author", "Tester").unwrap();
+4
View File
@@ -86,13 +86,16 @@ fn toolbar_and_view_menu_share_three_state_preferences() {
fn saved_preferences_restore_with_legacy_light_dark_settings() {
for (value, preference) in [("light", ThemePreference::Light), ("dark", ThemePreference::Dark), ("system", ThemePreference::System)] {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.set_option("theme", value).unwrap();
let saved = app.persist();
let mut restored = PdfCraftApp::new();
restored.set_option("language", "en").unwrap();
restored.restore(&saved);
assert_eq!(restored.theme_preference, preference);
}
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.restore(r#"{"theme":"Dark"}"#);
assert_eq!(app.theme_preference, ThemePreference::Dark);
assert_eq!(app.theme, ThemeKind::Dark);
@@ -107,6 +110,7 @@ fn saved_preferences_restore_with_legacy_light_dark_settings() {
#[test]
fn commands_and_options_apply_before_the_first_frame() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_command("view.theme.dark");
assert_eq!(app.theme_preference, ThemePreference::Dark);
assert_eq!(app.theme, ThemeKind::Dark);
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more