diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7f2730a3e7..821b7de74e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index ef2f96f832..45353f1b1b 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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 |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index d646dee265..9fe3bb1014 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -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"
diff --git a/Cargo.lock b/Cargo.lock
index 10531cb1fd..d43c0e0b68 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
]
diff --git a/README.md b/README.md
index 24db83c6f0..4334247808 100644
--- a/README.md
+++ b/README.md
@@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with ⌘G
## 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.
@@ -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.
diff --git a/ROADMAP.md b/ROADMAP.md
index e61995ad1b..a7fc14b1f6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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: - filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption; - cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing. Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open. 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: - filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption; - 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. Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open. 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: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. 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%).
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index e40607a85b..3d7f61a5ef 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -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);
}
diff --git a/contributors/people.toml b/contributors/people.toml
index 00beb65874..ade46dac11 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -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
diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml
index 9603e57224..8a31381493 100644
--- a/crates/annot/Cargo.toml
+++ b/crates/annot/Cargo.toml
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
+pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
diff --git a/crates/annot/README.md b/crates/annot/README.md
index c98cb6d2e8..38ad68b571 100644
--- a/crates/annot/README.md
+++ b/crates/annot/README.md
@@ -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
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index 6045743f23..11b2143480 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -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 {
// Imported measurement appearances may include leaders, captions and formatting that
@@ -444,11 +450,14 @@ pub fn build(d: &Dict) -> Option {
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 {
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")?;
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index 2abfdce553..18174c58d3 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -677,6 +677,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result Result {
+ 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));
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 21dbc3f5ff..29e9eb9e79 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -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 (` is outside the allowed directory `), 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.
diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs
index 4d97e3213b..094bf4984f 100644
--- a/crates/automation/src/comments.rs
+++ b/crates/automation/src/comments.rs
@@ -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;
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 83d5a411dd..e088a64f1d 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -642,7 +642,7 @@ pub fn tools() -> Vec {
})),
&["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(
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 093a34431d..c287d7c4fc 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -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");
diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs
index 983b53736f..808fdef160 100644
--- a/crates/cos/src/object.rs
+++ b/crates/cos/src/object.rs
@@ -223,19 +223,29 @@ impl Stream {
pub fn decoded_within(&self, max: usize) -> Result, 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, 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, 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");
+ }
}
diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs
index 858a306752..73311d2289 100644
--- a/crates/cos/src/writer.rs
+++ b/crates/cos/src/writer.rs
@@ -181,16 +181,17 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result,
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 = BTreeMap::new(); // num → (offset, gen, in use)
for (num, generation, obj) in doc.overlay_entries().collect::>() {
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,
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, 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 =
@@ -374,9 +381,10 @@ fn write_xref_table(out: &mut Vec, offsets: &BTreeMap
}
/// 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, trailer: &mut Dict, mut rows: BTreeMap, 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, base: u64, trailer: &mut Dict, mut rows: BTreeMap, 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));
diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs
index bfe85882f0..41158f10db 100644
--- a/crates/cos/tests/writer.rs
+++ b/crates/cos/tests/writer.rs
@@ -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 {
@@ -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 = (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 {
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 0a0830fdaf..6711ba4a14 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -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") {
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index d8a2dd933f..bf74edfb51 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -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 {
- 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());
+ }
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 2dca9559fa..22dd3d4cbb 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -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 {
let head = &f.bytes[..f.bytes.len().min(1024)];
@@ -2985,7 +2995,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result>,
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 {
- let rect = self.rect(at, initials)?;
+ pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option {
+ 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 {
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:?}");
+ }
+ }
}
diff --git a/crates/engine/src/upright_image.rs b/crates/engine/src/upright_image.rs
new file mode 100644
index 0000000000..dc2b9cdbfc
--- /dev/null
+++ b/crates/engine/src/upright_image.rs
@@ -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> {
+ 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 {
+ 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 {
+ 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:?}");
+ }
+}
diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
index e92d09ec16..ba165b09c8 100644
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -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(" 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());
}
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index 9c3ce6d148..1dc6ced27a 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -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};
diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs
index 88b77c061e..aeee43e1b1 100644
--- a/crates/fonts/src/pdf.rs
+++ b/crates/fonts/src/pdf.rs
@@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option {
o.and_then(|o| doc.resolve(o).as_dict().cloned())
}
-fn hex_bytes(tok: &str) -> Option> {
- 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>),
+ 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 {
+ 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> {
+ 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 {
+ 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, Vec)>, 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::()) {
- 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::()) {
- 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) {
- 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 = 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 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 {
+ 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>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> <0042> endbfchar".as_bytes(),
+ "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(),
+ b"1 beginbfchar <0001> <004",
+ b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar",
+ b"1 beginbfrange <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 [<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> <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> 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);
+ }
}
diff --git a/crates/model/README.md b/crates/model/README.md
index 90d6cebad3..19b457b61d 100644
--- a/crates/model/README.md
+++ b/crates/model/README.md
@@ -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).
diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs
index 6e558dea53..ff9e70c003 100644
--- a/crates/model/src/lib.rs
+++ b/crates/model/src/lib.rs
@@ -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],
}
}
diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml
index 2a5b974d44..0a42520eb0 100644
--- a/crates/render/Cargo.toml
+++ b/crates/render/Cargo.toml
@@ -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"
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 938525e65a..03af9fa271 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -304,30 +304,35 @@ pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result 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");
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 267be471d6..f9512df4c4 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -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 {
+ 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) -> 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>,
+ /// 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,
/// Test hook: make one page slow.
#[cfg(test)]
slow_page: Mutex>,
@@ -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>, config: RenderConfig, shared: Arc, wake: Receiver<()>, out: Sender) {
- 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 = ('\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};
diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs
index bedbf07257..949e7a92b1 100644
--- a/crates/sign/src/der.rs
+++ b/crates/sign/src/der.rs
@@ -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, 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, SignError> {
+ Tlv::only(input, Form::Ber)
+ }
+
+ fn only(input: &'a [u8], form: Form) -> Result, 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>, 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, 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, SignError> {
+ self.join(what, 0)
+ }
+
+ fn join(&self, what: &str, depth: usize) -> Result, 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 = 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 {
+ /// 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 };
diff --git a/crates/sign/src/pkcs12.rs b/crates/sign/src/pkcs12.rs
index 11d1e717dd..69d4e14e09 100644
--- a/crates/sign/src/pkcs12.rs
+++ b/crates/sign/src/pkcs12.rs
@@ -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, 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, 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, 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) -> 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) -> 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) -> 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 {
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 {
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)?;
diff --git a/crates/sign/tests/crypto.rs b/crates/sign/tests/crypto.rs
index f66179bfdf..ef9c570a13 100644
--- a/crates/sign/tests/crypto.rs
+++ b/crates/sign/tests/crypto.rs
@@ -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 {
+ 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 {
+ let parts: Vec = 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 {
+ 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();
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index f255d70e30..ed1e9a3276 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -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.
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 4030d7c6e4..beb88cc873 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -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 => {}
}
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 41df36e7c1..7ef9361a4e 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -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, 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);
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 6d3f97f9bd..126b7f319c 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -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.
diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs
index 37438d05d3..3d08ac95f1 100644
--- a/crates/ui-egui/src/comments.rs
+++ b/crates/ui-egui/src/comments.rs
@@ -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)
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 6a455cf47d..6cab546614 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -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)>,
}
-/// 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 {
+ 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.
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index a28a5485c9..57c2fef20b 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -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());
+ }
+}
diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs
index ecd4eb6f83..eb6204264f 100644
--- a/crates/ui-egui/src/fill_sign.rs
+++ b/crates/ui-egui/src/fill_sign.rs
@@ -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 {
+pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option {
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 => {
diff --git a/crates/ui-egui/src/folders_ui.rs b/crates/ui-egui/src/folders_ui.rs
index f5cd90b5d6..727417e2fc 100644
--- a/crates/ui-egui/src/folders_ui.rs
+++ b/crates/ui-egui/src/folders_ui.rs
@@ -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());
}
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 7e21c126b8..35f4efb68c 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -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());
}
}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
new file mode 100644
index 0000000000..08d8775b45
--- /dev/null
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -0,0 +1,2044 @@
+# Bulgarian (bg) catalog. Format: see the header of ja.tsv
+# (context source translation; @id, @plural and other contexts; escapes \n \t \\).
+# Plural forms (see `plural_one_other` in i18n/mod.rs): one (1) | other.
+# Clean-room: every entry was written from the plain meaning of the English label using ordinary
+# Bulgarian computing vocabulary. No proprietary localisation resource, string table or product UI
+# (Acrobat's included) was consulted or copied; add entries the same way.
+# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
+# the trailing "…" of dialog commands is kept.
+
+ About За програмата
+ Contributors Сътрудници
+ Models Модели
+ Show Покажи
+ Username Потребителско име
+ Display name Показвано име
+ Real name Истинско име
+ Sort Сортиране
+ Name (A–Z) Име (А–Я)
+ Merged PRs Обединени PR
+ PRs PR
+ Commits Къмитове
+ Lines added Добавени редове
+ Lines deleted Изтрити редове
+ Line delta Разлика в редовете
+ Binary assets added Добавени двоични ресурси
+ Binary assets removed Премахнати двоични ресурси
+ First commit Първи къмит
+ Last commit Последен къмит
+ +LOC +LOC
+ −LOC −LOC
+ ΔLOC ΔLOC
+ +Bin +Bin
+ −Bin −Bin
+ First Първи
+ Last Последен
+ Reverse the order Обърни реда
+ Grab bag Смесица
+ Table Таблица
+ {n} contributors · {c} commits {n} сътрудници · {c} къмита
+ No contributor data was built into this copy. В това копие няма вградени данни за сътрудниците.
+ @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} къмита, +{added} / −{deleted} реда (Δ {delta}), +{bin_added} / −{bin_deleted} двоични ресурса, {first} – {last}
+ No model credits were built into this copy. В това копие няма вградени данни за моделите.
+ Company Компания
+ Model Модел
+ % of all commits % от всички къмитове
+ Lines +/− Редове +/−
+ Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Продължете само ако имате доверие на този документ. Адресът може да пренася информация от документа, например въведеното във формуляра му.
+ Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Отпечатайте едностранно. Запазете реда на листовете, изрежете по маркировките, след което подредете купчините отляво надясно и отгоре надолу.
+ Menu Меню
+ File Файл
+ Edit Редактиране
+ Pages Страници
+ View Изглед
+ Help Помощ
+ Preferences Настройки
+ Preferences… Настройки…
+ Interface language Език на интерфейса
+ Identity Самоличност
+ Name on new comments Име в новите коментари
+ Open… Отвори…
+ Open Recent Последно отваряни
+ Pin folder to Home… Закачи папка в Начало…
+ Folders Папки
+ Pin a folder… Закачи папка…
+ Pin a folder to Home Закачи папка в Начало
+ Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Закачете папка, за да виждате тук най-новите PDF файлове в нея – например папката, в която записва скенерът ви или синхронизирано облачно хранилище.
+ Unpin Откачи
+ Unpin {folder} Откачи {folder}
+ Looking for PDFs… Търсене на PDF файлове…
+ This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Тази папка в момента не е достъпна. Може да е преместена или дискът, на който е, или облачната синхронизация да не са на линия.
+ No PDFs in this folder yet В тази папка все още няма PDF файлове
+ Show less Покажи по-малко
+ Show {n} more Покажи още {n}
+ The newest {n} of {total} PDFs Най-новите {n} от {total} PDF файла
+ Pinned folders need the desktop app Закачените папки изискват настолното приложение
+ Choose a folder on this computer to pin Изберете папка на този компютър, която да закачите
+ {folder} is already pinned to Home {folder} вече е закачена в Начало
+ You can pin up to {n} folders. Unpin one first. Можете да закачите до {n} папки. Първо откачете една.
+ Pinned {folder} to Home {folder} е закачена в Начало
+ No recent files Няма последни файлове
+ Open Отвори
+ Open file Отвори файл
+ Home Начало
+ New blank PDF Нов празен PDF
+ Create PDF from file… Създай PDF от файл…
+ Create PDF from images… Създай PDF от изображения…
+ Create PDF from multiple files… Създай PDF от няколко файла…
+ Create PDF from clipboard Създай PDF от клипборда
+ Combine files… Обедини файлове…
+ Save Запиши
+ Save as… Запиши като…
+ Close file Затвори файла
+ Close all Затвори всички
+ Revert Върни последно записаното
+ Print… Отпечатай…
+ Document properties… Свойства на документа…
+ Undo Отмени
+ Redo Повтори
+ Find… Намери…
+ Advanced search… Разширено търсене…
+ Copy pages Копирай страниците
+ Cut pages Изрежи страниците
+ Paste pages Постави страниците
+ Fit visible Побери видимото
+ Fit to height Побери по височина
+ Marquee zoom Мащабиране с рамка
+ Zoom in Увеличи
+ Zoom out Намали
+ Rotate view clockwise Завърти изгледа по часовниковата стрелка
+ Rotate view counterclockwise Завърти изгледа обратно на часовниковата стрелка
+ Take a snapshot Направи моментна снимка
+ Full screen mode Цял екран
+ Read mode Режим на четене
+ Switch light / dark theme Превключи светла / тъмна тема
+ Comments panel Панел с коментари
+ Form fields panel Панел с полета на формуляра
+ Signatures panel Панел с подписи
+ Clear form Изчисти формуляра
+ Find tools and commands… Намери инструменти и команди…
+ Find tools and commands Намери инструменти и команди
+ Zoom Мащаб
+ Actual size Действителен размер
+ Zoom to page level Мащаб до ниво страница
+ Fit to width Побери по ширина
+ Display theme Тема на показване
+ Side panels Странични панели
+ Enable Acrobat JavaScript Включи Acrobat JavaScript
+ OK OK
+ Cancel Отказ
+ Close Затвори
+ Don't save Не записвай
+ Delete Изтрий
+ Apply Приложи
+ Run Изпълни
+ Clear Изчисти
+ Sanitize Почисти
+ Mark all Маркирай всички
+ Save changes to “{name}” before closing? Да се запишат ли промените в „{name}“ преди затваряне?
+ Your changes will be lost if you don't save them. Промените ви ще бъдат загубени, ако не ги запишете.
+ Password required Изисква се парола
+ “{name}” is protected. Enter a password to open it. „{name}“ е защитен. Въведете парола, за да го отворите.
+ Password Парола
+ Incorrect password. Try again. Грешна парола. Опитайте отново.
+ Export a PDF Експортирай PDF
+ Fill & Sign Попълни и подпиши
+ Edit a PDF Редактирай PDF
+ Create a PDF Създай PDF
+ Combine files Обедини файлове
+ Add comments Добави коментари
+ Scan & OCR Сканиране и OCR
+ Protect a PDF Защити PDF
+ Redact a PDF Заличи в PDF
+ Compress a PDF Компресирай PDF
+ Add a stamp Добави печат
+ Use a certificate Използвай сертификат
+ Use print production Подготовка за печат
+ Measure objects Измерване на обекти
+ Compare files Сравни файлове
+ Use guided actions Използвай напътствани действия
+ Prepare for accessibility Подготви за достъпност
+ Apply PDF standards Приложи PDF стандарти
+ Add search index Добави индекс за търсене
+ Use JavaScript Използвай JavaScript
+ AI assistant AI асистент
+ Export to Експортирай в
+ Fill Попълни
+ Modify page Промени страница
+ Add content Добави съдържание
+ Create from Създай от
+ Combine Обедини
+ Page options Опции на страницата
+ More Още
+ Markup Маркировка
+ Drawing Рисуване
+ Review Преглед
+ Recognize text Разпознай текст
+ Recognize Text Разпознаване на текст
+ Delete selected fields Изтрий избраните полета
+ Password protection will be applied when you save Защитата с парола ще бъде приложена при записване
+ Saved the print-ready PDF to {path} Готовият за печат PDF е записан в {path}
+ Could not save: {e} Неуспешно записване: {e}
+ Earlier in tab order По-напред в реда на табулация
+ Later in tab order По-назад в реда на табулация
+ From: От:
+ To: До:
+ High Resolution Висока резолюция
+ Print Печат
+ Save as PDF/A Запиши като PDF/A
+ Verify to see what the document needs. Проверете, за да видите от какво се нуждае документът.
+ Set Page Boxes Задай полета на страницата
+ Signature panel Панел с подписи
+ {month} {day}, {y} {day} {month} {y} г.
+ The document's security settings don't allow filling in form fields Настройките за защита на документа не позволяват попълване на полетата на формуляра
+ {name} is read-only {name} е само за четене
+ {name} has no action {name} няма действие
+ Previous month Предишен месец
+ Next month Следващ месец
+ January Януари
+ February Февруари
+ March Март
+ April Април
+ May Май
+ June Юни
+ July Юли
+ August Август
+ September Септември
+ October Октомври
+ November Ноември
+ December Декември
+ Done Готово
+ This certificate is in your list of trusted certificates. Този сертификат е в списъка ви с надеждни сертификати.
+ This certificate is trusted through a certificate above it in the chain. Този сертификат е надежден чрез сертификат над него във веригата.
+ This certificate is not trusted. Signatures made with it show an unknown identity. Този сертификат не е надежден. Подписите, направени с него, показват неизвестна самоличност.
+ Add to Trusted Certificates Добави към надеждните сертификати
+ Export… Експортирай…
+ PDF Optimizer PDF оптимизатор
+ Discard Objects Премахване на обекти
+ Discard User Data Премахване на потребителски данни
+ Clean Up Почистване
+ Color Images Цветни изображения
+ Grayscale Images Изображения в сива скала
+ Bicubic downsampling to Бикубично понижаване на резолюцията до
+ for images above за изображения над
+ Compression Компресия
+ ZIP ZIP
+ Retain existing Запази съществуващата
+ Minimum Минимално
+ Low Ниско
+ High Високо
+ Maximum Максимално
+ Each image is measured where pages draw it; an image is replaced only if the result is smaller. Всяко изображение се измерва там, където страниците го изобразяват; изображението се заменя само ако резултатът е по-малък.
+ Discard all links, actions and JavaScript Премахни всички връзки, действия и JavaScript
+ Discard alternate images Премахни алтернативните изображения
+ Discard embedded page thumbnails Премахни вградените миниатюри на страниците
+ Discard document tags Премахни таговете на документа
+ Discard embedded print settings Премахни вградените настройки за печат
+ Discard bookmarks Премахни отметките
+ Discard hidden layer content Премахни съдържанието на скритите слоеве
+ Discard all comments, forms and multimedia Премахни всички коментари, формуляри и мултимедия
+ Discard document information and metadata Премахни информацията за документа и метаданните
+ Discard all object data (file attachments) Премахни всички данни на обекти (прикачени файлове)
+ Discard private data of other applications Премахни частните данни на други приложения
+ Discard hidden text Премахни скрития текст
+ Use Flate to encode streams that are not encoded Кодирай с Flate некодираните потоци
+ Remove invalid links and bookmarks Премахни невалидните връзки и отметки
+ Remove unreferenced named destinations Премахни неизползваните именувани местоназначения
+ Compress document structure (object streams) Компресирай структурата на документа (потоци от обекти)
+ Remove unused objects and merge identical ones Премахни неизползваните обекти и обедини еднаквите
+ Audit space usage… Анализ на използваното място…
+ Space Audit Анализ на мястото
+ Bytes Байтове
+ Content Streams Потоци на съдържанието
+ X Object Forms Форми X Object
+ Structure Info Информация за структурата
+ Thumbnails Миниатюри
+ Named Destinations Именувани местоназначения
+ Color Spaces Цветови пространства
+ Shadings Преливания
+ Patterns (graphics objects) Шарки (графични обекти)
+ Extended Graphics States Разширени графични състояния
+ Embedded Files Вградени файлове
+ Document Overhead Служебни данни на документа
+ Total Общо
+ Text Field Properties Свойства на текстово поле
+ Check Box Properties Свойства на отметка
+ Radio Button Properties Свойства на радиобутон
+ Dropdown Properties Свойства на падащ списък
+ List Box Properties Свойства на списъчно поле
+ Button Properties Свойства на бутон
+ Digital Signature Properties Свойства на цифров подпис
+ Position Позиция
+ Options Опции
+ Validate Проверка
+ Calculate Изчисляване
+ Actions Действия
+ This field is locked. Unlock it to change its properties. Това поле е заключено. Отключете го, за да промените свойствата му.
+ Tooltip: Подсказка:
+ Common Properties Общи свойства
+ Read Only Само за четене
+ Required Задължително
+ Font Size: Размер на шрифта:
+ Auto Автоматично
+ Text Color: Цвят на текста:
+ Borders and Colors Рамки и цветове
+ Border Color: Цвят на рамката:
+ Fill Color: Цвят на запълване:
+ No color Без цвят
+ Beveled Скосена
+ Left: Ляво:
+ Bottom: Долу:
+ Width: Ширина:
+ Height: Височина:
+ Points from the page's bottom-left corner. Точки от долния ляв ъгъл на страницата.
+ Alignment: Подравняване:
+ Default Value: Стойност по подразбиране:
+ Multi-line Многоредово
+ Scroll long text Превъртай дълъг текст
+ Allow Rich Text Formatting Разреши форматиране на текста
+ Limit of Ограничение от
+ characters знака
+ Field is used for file selection Полето се използва за избор на файл
+ Check spelling Проверка на правописа
+ Comb of Разделено на
+ Export Value: {on} Стойност за експорт: {on}
+ Check box is checked by default Отметката е поставена по подразбиране
+ Button is checked by default Бутонът е избран по подразбиране
+ Buttons with the same name and value are selected in unison Бутоните с еднакви име и стойност се избират едновременно
+ Item: Елемент:
+ Item List Списък с елементи
+ Up Нагоре
+ Delete {o} Изтрий {o}
+ Add the choices people pick from. Добавете възможностите, от които потребителите избират.
+ Default: По подразбиране:
+ (none) (няма)
+ Sort items Сортирай елементите
+ Allow user to enter custom text Разреши на потребителя да въвежда собствен текст
+ Multiple selection Множествен избор
+ Commit selected value immediately Приеми избраната стойност веднага
+ Number Число
+ Percentage Процент
+ Time Час
+ Special Специален
+ Select format category: Изберете категория формат:
+ The value is shown as typed. Стойността се показва както е въведена.
+ Decimal places: Знаци след десетичната запетая:
+ Separator style: Стил на разделителя:
+ Currency symbol: Символ за валута:
+ Before the number Пред числото
+ Negative number style: Стил на отрицателните числа:
+ Format: Формат:
+ Custom: Персонализиран:
+ Zip Code Пощенски код
+ Zip Code + 4 Пощенски код + 4
+ Phone Number Телефонен номер
+ Social Security Number Номер на социално осигуряване (САЩ)
+ Arbitrary Mask Произволна маска
+ Mask: Маска:
+ 9 digit, A letter, O letter or digit, X any character 9 цифра, A буква, O буква или цифра, X произволен знак
+ Example: {sample} Пример: {sample}
+ Field value is not validated Стойността на полето не се проверява
+ Field value is in range: Стойността на полето е в диапазона:
+ Add an Action Добави действие
+ Select Trigger: Изберете задействане:
+ Mouse Up Отпускане на бутона на мишката
+ Mouse Down Натискане на бутона на мишката
+ Mouse Enter Влизане на мишката
+ Mouse Exit Излизане на мишката
+ On Focus При фокусиране
+ On Blur При загуба на фокус
+ Select Action: Изберете действие:
+ Open a web link Отвори уеб връзка
+ Reset a form Нулирай формуляр
+ Execute a menu item Изпълни елемент от менюто
+ Show a field Покажи поле
+ Hide a field Скрий поле
+ Submit a form Изпрати формуляр
+ Fields to reset (comma-separated; empty: all) Полета за нулиране (разделени със запетая; празно: всички)
+ Print, NextPage, PrevPage, FirstPage or LastPage Print, NextPage, PrevPage, FirstPage или LastPage
+ Field names (comma-separated) Имена на полета (разделени със запетая)
+ URL to submit to URL за изпращане
+ Script: Скрипт:
+ Value: Стойност:
+ No actions Няма действия
+ Value is not calculated Стойността не се изчислява
+ Value is the Стойността е
+ sum (+) сумата (+)
+ product (x) произведението (x)
+ average средното
+ minimum минимума
+ maximum максимума
+ of the following fields: на следните полета:
+ Simplified field notation: Опростен запис с полета:
+ Field names with + - * / and parentheses; put \\ before spaces in names. Имена на полета с + - * / и скоби; поставете \\ пред интервалите в имената.
+ Format text Форматирай текст
+ Bold Удебелен
+ Italic Курсив
+ Align left Подравни вляво
+ Centre Центрирай
+ Align right Подравни вдясно
+ Justify Двустранно подравняване
+ Standard fonts; text outside Windows-1252 isn't supported yet. Стандартни шрифтове; текст извън Windows-1252 все още не се поддържа.
+ Edit image Редактирай изображение
+ Rotate counterclockwise Завърти обратно на часовниковата стрелка
+ Rotate clockwise Завърти по часовниковата стрелка
+ Flip horizontal Обърни хоризонтално
+ Flip vertical Обърни вертикално
+ Replace image Замени изображението
+ Crop (% trimmed from each side) Изрязване (% отрязани от всяка страна)
+ Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. Щракнете върху страницата, за да добавите текст; всяко щракване създава ново поле, а ✓ завършва. Плъзнете елементите, за да ги преместите, плъзнете ъгъл, за да промените размера, щракнете двукратно върху текст, за да го редактирате.
+ Choose an image Изберете изображение
+ Export to Image Експортиране като изображение
+ Export to Text Експортиране като текст
+ Export All Images Експортиране на всички изображения
+ Resolution Резолюция
+ Format Формат
+ Quality Качество
+ One {f} file per page, named after the document. По един {f} файл на страница, именуван по документа.
+ Exclude images smaller than Изключи изображения, по-малки от
+ No limit Без ограничение
+ Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. Всяко изображение веднъж, именувано по документа и страницата. JPEG изображенията се записват непроменени, а останалите – като PNG.
+ Plain text in reading order; pages are separated by form feeds. Обикновен текст в реда на четене; страниците се разделят със знаци за нова страница.
+ Create {what} Създай {what}
+ initials инициали
+ signature подпис
+ Create Custom Stamp Създаване на персонализиран печат
+ From {file} От {file}
+ Category: Категория:
+ e.g. My stamps напр. Моите печати
+ Name: Име:
+ Custom Персонализиран
+ Delete stamp Изтрий печата
+ Create custom stamp… Създай персонализиран печат…
+ From a PDF page or an image От страница на PDF или изображение
+ Line spacing Междуредие
+ Character spacing Разредка между знаците
+ Horizontal scale Хоризонтален мащаб
+ Rotate Clockwise Завърти по часовниковата стрелка
+ Rotate Counterclockwise Завърти обратно на часовниковата стрелка
+ Flip Horizontal Обърни хоризонтално
+ Flip Vertical Обърни вертикално
+ Replace Image… Замени изображението…
+ Save Image As… Запиши изображението като…
+ Check for updates Провери за актуализации
+ No check has run yet. Още не е извършвана проверка.
+ Checking for a newer version… Проверка за по-нова версия…
+ PdfCraft {v} is available. Налична е PdfCraft {v}.
+ You have version {c}. Download the new version from its release page. Имате версия {c}. Изтеглете новата версия от страницата на изданието.
+ PdfCraft {c} is up to date. PdfCraft {c} е актуална.
+ Couldn't check for updates: {e} Неуспешна проверка за актуализации: {e}
+ You have version {c}. All releases are listed at {page}. Имате версия {c}. Всички издания са изброени на {page}.
+ Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Запитва GitHub за най-новото издание. Нищо не се изтегля и не се инсталира автоматично.
+ Download Изтегли
+ Later По-късно
+ none няма
+ Declared conformance: {shown} Декларирано съответствие: {shown}
+ Output intent: {list} Изходно предназначение: {list}
+ Conformance level: Ниво на съответствие:
+ No problems found: the document conforms to {level}. Не са открити проблеми: документът съответства на {level}.
+ (page {p}) (страница {p})
+ · not fixable here · не може да се поправи тук
+ Verify Провери
+ Box Поле
+ Units Мерни единици
+ Set to zero Задай нула
+ Current page ({n}) Текуща страница ({n})
+ Subset Подмножество
+ Update Актуализирай
+ Add Добави
+ {verb} Header and Footer {verb} горен и долен колонтитул
+ {verb} Watermark {verb} воден знак
+ {verb} Background {verb} фон
+ Left Header Text Текст на горния колонтитул вляво
+ Center Header Text Текст на горния колонтитул в центъра
+ Right Header Text Текст на горния колонтитул вдясно
+ Left Footer Text Текст на долния колонтитул вляво
+ Center Footer Text Текст на долния колонтитул в центъра
+ Right Footer Text Текст на долния колонтитул вдясно
+ Insert Page Number Вмъкни номер на страница
+ Insert Date Вмъкни дата
+ Start page number Начален номер на страница
+ Source Източник
+ Fit to page Побери в страницата
+ Rotation Завъртане
+ Appear behind page Под съдържанието на страницата
+ Appear on top of page Над съдържанието на страницата
+ From colour От цвят
+ Page Range Options Опции за диапазон на страниците
+ Preview: page {p} of {n} Преглед: страница {p} от {n}
+ Applies to 1 page Прилага се към 1 страница
+ Applies to {n} pages Прилага се към {n} страници
+ Replaces the existing one on those pages. Заменя съществуващия на тези страници.
+ PDF or image PDF или изображение
+ No file chosen Не е избран файл
+ Page number Номер на страница
+ Scale relative to target page Мащабирай спрямо целевата страница
+ Link Properties Свойства на връзката
+ Create Link Създай връзка
+ Link Appearance Външен вид на връзката
+ Link Type: Тип на връзката:
+ Visible Rectangle Видим правоъгълник
+ Invisible Rectangle Невидим правоъгълник
+ Highlight Style: Стил на маркиране:
+ Invert Инвертиране
+ Outline Контур
+ Inset Вдлъбнато
+ Line Thickness: Дебелина на линията:
+ Thin Тънка
+ Thick Дебела
+ Line Style: Стил на линията:
+ Solid Плътна
+ Dashed Пунктирна
+ Color: Цвят:
+ Link Action Действие на връзката
+ Open a web page Отвори уеб страница
+ Go to a page view Отиди на изглед на страница
+ Type the web address. Въведете уеб адреса.
+ This document has no signatures. Този документ няма подписи.
+ The Keychain's digital IDs couldn't be listed: {e} Цифровите идентификатори от Keychain не можаха да бъдат изброени: {e}
+ Signed and saved to {path} Подписано и записано в {path}
+ Couldn't open revision {n}: {e} Неуспешно отваряне на редакция {n}: {e}
+ Couldn't open the signed version: {e} Неуспешно отваряне на подписаната версия: {e}
+ Sign with a Digital ID Подпиши с цифров идентификатор
+ Choose the digital ID that you want to use for signing: Изберете цифровия идентификатор, с който искате да подпишете:
+ Keychain · Keychain ·
+ Windows store · Хранилище на Windows ·
+ Issued by: Издаден от:
+ , Expires: , валиден до:
+ No digital IDs yet. Все още няма цифрови идентификатори.
+ Configure New Digital ID Конфигурирай нов цифров идентификатор
+ Continue Продължи
+ Configure a Digital ID for Signing Конфигуриране на цифров идентификатор за подписване
+ Use a Digital ID from a file Използвай цифров идентификатор от файл
+ Create a new Digital ID (self-signed, saved to a password-protected file) Създай нов цифров идентификатор (самоподписан, записан във файл, защитен с парола)
+ Name Име
+ Organizational Unit Организационна единица
+ Organization Name Име на организацията
+ Email Address Имейл адрес
+ Country/Region Държава/регион
+ Key Algorithm Алгоритъм на ключа
+ Confirm Password Потвърдете паролата
+ Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. Валиден 5 години, за цифрови подписи. Самоподписаните идентификатори обикновено не се ползват с доверие от другите.
+ File (.p12, .pfx) Файл (.p12, .pfx)
+ Digital ID Цифров идентификатор
+ Browse… Преглед…
+ {verb} as "{name}" {verb} като „{name}“
+ Certify Сертифицирай
+ The signature will be invisible. Подписът ще бъде невидим.
+ Distinguished name Отличително име
+ Labels Етикети
+ Permitted actions after certifying Разрешени действия след сертифициране
+ No changes allowed Не се разрешават промени
+ Annotations, form fill-in, and digital signatures Анотации, попълване на формуляри и цифрови подписи
+ Form fill-in and digital signatures Попълване на формуляри и цифрови подписи
+ The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. Ключът е в macOS Keychain, който може да поиска разрешение PdfCraft да го използва.
+ The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. Ключът е в хранилището за сертификати на Windows, което може да поиска разрешение PdfCraft да го използва.
+ Digital ID password Парола за цифровия идентификатор
+ Back Назад
+ Validate all Провери всички
+ Sign this field… Подпиши това поле…
+ Signature is valid: Подписът е валиден:
+ Signature validity is UNKNOWN: Валидността на подписа е НЕИЗВЕСТНА:
+ Signature is INVALID: Подписът е НЕВАЛИДЕН:
+ Signature Details Подробности за подписа
+ Reason Причина
+ Signing time Час на подписване
+ Algorithm Алгоритъм
+ Signer Подписал
+ Issued by Издаден от
+ Valid Валиден
+ Field Поле
+ Field: {field} on page {p} Поле: {field} на страница {p}
+ Add to trusted certificates Добави към надеждните сертификати
+ View signed version Покажи подписаната версия
+ Show certificate… Покажи сертификата…
+ Export certificate… Експортирай сертификата…
+ Certificate Viewer Преглед на сертификати
+ This dialog shows the details of a certificate and its chain. Този диалог показва подробностите за сертификат и неговата верига.
+ Trust Доверие
+ Issued to Издаден на
+ Valid from Валиден от
+ Valid to Валиден до
+ Intended usage Предназначение
+ Any Всяко
+ Version Версия
+ Serial number Сериен номер
+ Issuer Издател
+ Validity starts Начало на валидността
+ Validity ends Край на валидността
+ Public key Публичен ключ
+ Key usage Употреба на ключа
+ Not present Няма
+ Self-signed Самоподписан
+ Edit Action Редактиране на действие
+ New Action Ново действие
+ Action name: Име на действието:
+ Description: Описание:
+ Steps Стъпки
+ Add the steps this action runs, in order. Добавете стъпките, които това действие изпълнява, по ред.
+ Add Step Добави стъпка
+ An action with this name already exists. Вече съществува действие с това име.
+ New Action… Ново действие…
+ Edit… Редактирай…
+ Add watermark Добави воден знак
+ Add header Добави горен колонтитул
+ Add footer Добави долен колонтитул
+ Set document title Задай заглавие на документа
+ Run a JavaScript Изпълни JavaScript
+ Prepare for Distribution Подготовка за разпространение
+ Remove hidden information and flatten comments and form fields, so recipients see only the pages. Премахва скритата информация и сплесква коментарите и полетата на формуляри, така че получателите да виждат само страниците.
+ Optimize Scanned Documents Оптимизиране на сканирани документи
+ Make scans searchable, then make them smaller. Прави сканираните документи търсими, след което ги смалява.
+ Add Page Numbers Добавяне на номера на страници
+ Number every page in the footer. Номерира всяка страница в долния колонтитул.
+ Mark as Confidential Маркиране като поверително
+ A diagonal CONFIDENTIAL watermark on every page. Диагонален воден знак CONFIDENTIAL на всяка страница.
+ Sanitize Documents Почистване на документи
+ Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Премахва скритата информация, метаданните, скриптовете и вграденото съдържание и презаписва файловете.
+ Type text Въведете текст
+ Text to insert Текст за вмъкване
+ Replacement text Заместващ текст
+ Edit text… Редактирай текста…
+ Add a sticky note here Добави бележка тук
+ {kind} Properties Свойства: {kind}
+ Appearance Външен вид
+ General Общи
+ Review History История на прегледа
+ This comment's appearance can't be changed yet. Външният вид на този коментар все още не може да се променя.
+ Icon Икона
+ Thickness Дебелина
+ Modified Променено
+ No status has been set. Не е зададено състояние.
+ Compare Files Сравняване на файлове
+ Old file: Стар файл:
+ Open the older version first Първо отворете по-старата версия
+ New file: Нов файл:
+ Text is compared word by word; the differences are shown on the new file. Текстът се сравнява дума по дума; разликите се показват в новия файл.
+ Compare this file with an older version: Compare files… in All tools. Сравнете този файл с по-стара версия: „Сравни файлове…“ във „Всички инструменти“.
+ No differences in the text. Няма разлики в текста.
+ {n} {kind} {n} {kind}
+ Inserted Вмъкнато
+ Deleted Изтрито
+ Replaced Заменено
+ Visual Визуално
+ Mark as comments Маркирай като коментари
+ Report… Отчет…
+ {kind} · page {p} {kind} · страница {p}
+ Set Alternate Text Задаване на алтернативен текст
+ This document has no tagged figures. Този документ няма етикетирани фигури.
+ on page {p} на страница {p}
+ Figure {i} of {n}{page} Фигура {i} от {n}{page}
+ No preview Няма преглед
+ Alternate text Алтернативен текст
+ Describe the figure Опишете фигурата
+ Decorative figure Декоративна фигура
+ Previous figure Предишна фигура
+ Next figure Следваща фигура
+ Save & Close Запиши и затвори
+ Accessibility Checker Options Опции на проверката за достъпност
+ Report Options Опции на отчета
+ Create accessibility report Създай отчет за достъпност
+ Page Range Диапазон на страниците
+ All pages in document Всички страници в документа
+ Checking Options ({on} of 32 in all categories) Опции за проверка ({on} от 32 във всички категории)
+ Show this dialog when the Checker starts Показвай този диалог при стартиране на проверката
+ Select All Избери всички
+ Clear All Изчисти всички
+ Start Checking Започни проверката
+ This document hasn't been checked yet. Този документ все още не е проверен.
+ No issues found Не са открити проблеми
+ 1 issue 1 проблем
+ {n} issues {n} проблема
+ {label} (1 issue) {label} (1 проблем)
+ {label} ({n} issues) {label} ({n} проблема)
+ Check again Провери отново
+ Report Отчет
+ Show the accessibility report Покажи отчета за достъпност
+ Collapse Свий
+ Expand Разгъни
+ {rule} - {status} {rule} - {status}
+ Passed Успешно
+ Failed Неуспешно
+ Needs manual check Нужна е ръчна проверка
+ Skipped Пропуснато
+ Fix Поправи
+ Skip Rule Пропусни правилото
+ Explain Обясни
+ Check Again Провери отново
+ Show Report Покажи отчета
+ Options… Опции…
+ Go to the page Отиди на страницата
+ Page Content Съдържание на страниците
+ Forms Формуляри
+ Alternate Text Алтернативен текст
+ Tables Таблици
+ Lists Списъци
+ Headings Заглавия
+ Accessibility permission flag Флаг за разрешение за достъпност
+ Accessibility permission flag is set Флагът за разрешение за достъпност е зададен
+ Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Настройките за защита трябва да позволяват на помощните технологии да четат съдържанието („Разреши достъп до текста за екранни четци“). Променете защитата на документа, за да го позволите.
+ Image-only PDF PDF само с изображения
+ Document is not image-only PDF Документът не е PDF само с изображения
+ Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. Страниците, които са само снимки на текст, не могат да се четат на глас или да се търсят. Разпознайте текста (OCR), за да добавите текстов слой.
+ Document is tagged PDF Документът е етикетиран PDF
+ Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). Етикетите придават на документа логическа структура, която помощните технологии следват. Етикетирайте документа („Автоматично етикетиране на PDF“).
+ Logical Reading Order Логически ред на четене
+ Document structure provides a logical reading order Структурата на документа осигурява логически ред на четене
+ Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Проверете дали етикетите се четат в реда, в който би чел зрящ читател. Използвайте инструмента „Ред на четене“ или панела „Етикети“, за да го коригирате.
+ Primary language Основен език
+ Text language is specified Езикът на текста е зададен
+ Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. Екранните четци избират гласа си според езика на документа. Задайте го в „Свойства на документа > Разширени > Език“.
+ Document title is showing in title bar Заглавието на документа се показва в заглавната лента
+ The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. Прозорецът трябва да показва смислено заглавие вместо името на файла. Задайте заглавие и „Начален изглед > Показване: Заглавие на документа“.
+ Bookmarks are present in large documents В големите документи има отметки
+ Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. Документите с повече от 20 страници се нуждаят от отметки за навигация. Добавете отметки за основните раздели.
+ Color contrast Цветови контраст
+ Document has appropriate color contrast Документът има подходящ цветови контраст
+ Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. Текстът трябва да контрастира достатъчно с фона си за хора с намалено зрение. Проверете цветовете на око или с инструмент за контраст.
+ Tagged content Етикетирано съдържание
+ All page content is tagged Цялото съдържание на страниците е етикетирано
+ Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. Всичко на страницата трябва да е етикетирано или маркирано като артефакт (украса, горни и долни колонтитули). Етикетирайте неетикетираното съдържание.
+ Tagged annotations Етикетирани анотации
+ All annotations are tagged Всички анотации са етикетирани
+ Comments, links and fields must be in the tag tree to be announced. Tag the annotations. Коментарите, връзките и полетата трябва да са в дървото на етикетите, за да бъдат обявявани. Етикетирайте анотациите.
+ Tab order Ред на табулация
+ Tab order is consistent with structure order Редът на табулация съответства на реда на структурата
+ Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. Страниците с връзки, коментари или полета трябва да се обхождат с Tab по реда на структурата. Поправката задава реда на табулация на всяка страница на „Използвай структурата на документа“.
+ Character encoding Кодиране на знаците
+ Reliable character encoding is provided Осигурено е надеждно кодиране на знаците
+ Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. Шрифтовете трябва да съпоставят знаците си с Unicode, за да може текстът да се чете и копира. Поправете шрифтовете или създайте PDF файла наново.
+ Tagged multimedia Етикетирана мултимедия
+ All multimedia objects are tagged Всички мултимедийни обекти са етикетирани
+ Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. Видеото, звукът и мултимедийното съдържание трябва да са етикетирани, за да бъдат обявявани. Етикетирайте мултимедийните анотации.
+ Screen flicker Трептене на екрана
+ Page will not cause screen flicker Страницата няма да предизвика трептене на екрана
+ Animation that flashes can cause seizures. Check scripts and media for flicker. Мигащата анимация може да предизвика пристъпи. Проверете скриптовете и мултимедията за трептене.
+ Scripts Скриптове
+ No inaccessible scripts Няма недостъпни скриптове
+ Timed responses Времеви ограничения за отговор
+ Page does not require timed responses Страницата не изисква отговор в ограничено време
+ People may need more time to respond. Check that nothing has a time limit. Някои хора може да се нуждаят от повече време за отговор. Проверете дали нищо няма времево ограничение.
+ Navigation links Навигационни връзки
+ Navigation links are not repetitive Навигационните връзки не се повтарят
+ Repeated navigation links should be skippable. Check links that appear on every page. Повтарящите се навигационни връзки трябва да могат да се пропускат. Проверете връзките, които се появяват на всяка страница.
+ Tagged form fields Етикетирани полета на формуляри
+ All form fields are tagged Всички полета на формуляри са етикетирани
+ Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. Полетата на формуляри трябва да са в дървото на етикетите (етикети Form), за да бъдат обявявани. Етикетирайте полетата на формуляра.
+ Field descriptions Описания на полетата
+ All form fields have description Всички полета на формуляри имат описание
+ Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. Всяко поле се нуждае от подсказка, която казва какво да се въведе. Добавете я в „Свойства на полето > Общи > Подсказка“.
+ Figures alternate text Алтернативен текст на фигурите
+ Figures require alternate text Фигурите изискват алтернативен текст
+ Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. Изображенията се нуждаят от алтернативен текст, който ги описва, или трябва да са маркирани като декоративни. Добавете алтернативен текст към всяка фигура.
+ Nested alternate text Вложен алтернативен текст
+ Alternate text that will never be read Алтернативен текст, който никога няма да бъде прочетен
+ Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. Алтернативен текст в елемент, който вече има алтернативен текст, никога не се прочита. Премахнете вътрешния алтернативен текст.
+ Associated with content Свързан със съдържание
+ Alternate text must be associated with some content Алтернативният текст трябва да е свързан с някакво съдържание
+ Alternate text on an element without page content is never reached. Remove it or attach the element to content. Алтернативен текст в елемент без съдържание на страницата никога не се достига. Премахнете го или свържете елемента със съдържание.
+ Hides annotation Скрива анотация
+ Alternate text should not hide annotation Алтернативният текст не трябва да скрива анотация
+ Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. Алтернативен текст в елемент, който съдържа връзка или поле, скрива тази анотация от помощните технологии. Преместете алтернативния текст.
+ Other elements alternate text Алтернативен текст на други елементи
+ Other elements that require alternate text Други елементи, които изискват алтернативен текст
+ Formulas and similar non-text elements need alternate text. Add alternate text to them. Формулите и подобни нетекстови елементи се нуждаят от алтернативен текст. Добавете им алтернативен текст.
+ Rows Редове
+ TR must be a child of Table, THead, TBody, or TFoot TR трябва да е дъщерен елемент на Table, THead, TBody или TFoot
+ Table cells belong in a row. Fix the table's tags. Клетките на таблица трябва да са в ред. Поправете етикетите на таблицата.
+ Headers Заглавни клетки
+ Tables should have headers Таблиците трябва да имат заглавни клетки
+ Header cells (TH) tell listeners what each column or row holds. Mark the header cells. Заглавните клетки (TH) казват на слушателите какво съдържа всяка колона или ред. Маркирайте заглавните клетки.
+ Regularity Регулярност
+ Tables must contain the same number of columns in each row and rows in each column Таблиците трябва да имат еднакъв брой колони във всеки ред и редове във всяка колона
+ Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). Неравномерните таблици объркват навигацията по клетки. Дайте на всеки ред еднакъв брой клетки (като броите обединените).
+ Summary Резюме
+ Tables must have a summary Таблиците трябва да имат резюме
+ A summary describes the table's purpose and layout. Add a Summary attribute to each table. Резюмето описва предназначението и оформлението на таблицата. Добавете атрибут Summary към всяка таблица.
+ List items Елементи на списък
+ LI must be a child of L LI трябва да е дъщерен елемент на L
+ List items belong in a list. Fix the list's tags. Елементите на списък трябва да са в списък. Поправете етикетите на списъка.
+ Lbl and LBody Lbl и LBody
+ Lbl and LBody must be children of LI Lbl и LBody трябва да са дъщерни елементи на LI
+ List labels and bodies belong in a list item. Fix the list's tags. Етикетите и телата на списъка трябва да са в елемент на списък. Поправете етикетите на списъка.
+ Appropriate nesting Правилно влагане
+ Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. Нивата на заглавията не трябва да се прескачат (H1, последвано от H3). Етикетирайте заглавията наново.
+ TH and TD TH и TD
+ TH and TD must be children of TR TH и TD трябва да са дъщерни елементи на TR
+ Mark Page Range Маркирай диапазон от страници
+ Pages from Страници от
+ Single word or phrase Отделна дума или фраза
+ Text to find Текст за търсене
+ Patterns Шаблони
+ Phone Numbers Телефонни номера
+ Email Addresses Имейл адреси
+ Credit Cards Кредитни карти
+ Social Security Numbers Номера на социално осигуряване (САЩ)
+ Dates Дати
+ Text in images isn't found; recognise text (OCR) first. Текстът в изображения не се открива; първо разпознайте текста (OCR).
+ No matches. Няма съвпадения.
+ {n} match(es) marked for redaction. {n} съвпадение(я) маркирани за заличаване.
+ Redaction Tool Properties Свойства на инструмента за заличаване
+ Redacted area fill colour: Цвят на запълване на заличената област:
+ Use overlay text Използвай текст върху заличаването
+ Custom text: Потребителски текст:
+ Font: Шрифт:
+ Font size: Размер на шрифта:
+ Auto-size text to fit redaction region Автоматично оразмеряване на текста според областта на заличаване
+ Font colour: Цвят на шрифта:
+ Repeat overlay text Повтаряй текста върху заличаването
+ Text alignment: Подравняване на текста:
+ Center Центрирано
+ Apply redactions Приложи заличаванията
+ You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. На път сте да приложите 1 маркировка за заличаване. Текстът, изображенията и чертежите под маркировките, както и коментарите и полетата на формуляри, които ги припокриват, се премахват завинаги.
+ You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. На път сте да приложите {n} маркировки за заличаване. Текстът, изображенията и чертежите под маркировките, както и коментарите и полетата на формуляри, които ги припокриват, се премахват завинаги.
+ Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. След това запишете документа: записването презаписва целия файл, така че в него да не остане следа от премахнатото съдържание.
+ Select the items to remove from this document. Изберете елементите, които да се премахнат от документа.
+ Metadata Метаданни
+ File attachments Прикачени файлове
+ Comments and markups Коментари и маркировки
+ Hidden text Скрит текст
+ Hidden layers Скрити слоеве
+ Links, actions and JavaScripts Връзки, действия и JavaScript скриптове
+ Private data of other applications Лични данни на други приложения
+ None found Няма открити
+ Form fields are flattened: their values stay visible. Saving rewrites the whole file. Полетата на формуляри се сплескват: стойностите им остават видими. Записването презаписва целия файл.
+ Remove Премахни
+ Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. Почистването премахва скритата информация от документа: метаданни, прикачени файлове, коментари, полета на формуляри (сплескани), скрит текст и слоеве, отметки, връзки, действия и скриптове, както и лични данни на приложения.
+ Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. След това запишете документа; записването презаписва целия файл, така че в него да не остане нищо от премахнатото.
+ Printer: Принтер:
+ Save as PDF Запиши като PDF
+ {name} (default) {name} (по подразбиране)
+ Copies: Копия:
+ Collate Подреди по копия
+ Print in grayscale Печат в сива скала
+ Two-sided: Двустранен печат:
+ Off Изключено
+ Flip on long edge Обръщане по дългия ръб
+ Flip on short edge Обръщане по късия ръб
+ Paper: Хартия:
+ Pages to Print Страници за печат
+ More options: Още опции:
+ All pages in range Всички страници в диапазона
+ Odd pages only Само нечетните страници
+ Even pages only Само четните страници
+ Reverse pages Обърнат ред на страниците
+ Page Sizing & Handling Размер и обработка на страниците
+ Size Размер
+ Poster Плакат
+ Multiple Няколко
+ Booklet Брошура
+ Fit Побери
+ Shrink oversized pages Смали прекалено големите страници
+ Custom scale: Потребителски мащаб:
+ Tile scale: Мащаб на плочките:
+ Overlap: Застъпване:
+ Cut marks Маркери за рязане
+ Pages per sheet: Страници на лист:
+ Page order: Ред на страниците:
+ Horizontal Хоризонтално
+ Horizontal reversed Хоризонтално обратно
+ Vertical Вертикално
+ Vertical reversed Вертикално обратно
+ Print page border Отпечатай рамка на страницата
+ Auto-rotate pages Автоматично завъртане на страниците
+ Booklet subset: Част от брошурата:
+ Both sides И двете страни
+ Front side only Само лицева страна
+ Back side only Само обратна страна
+ Binding: Подвързване:
+ Left Ляво
+ Right Дясно
+ Orientation: Ориентация:
+ Auto portrait/landscape Автоматично портрет/пейзаж
+ Portrait Портрет
+ Landscape Пейзаж
+ Comments & Forms Коментари и формуляри
+ Document Документ
+ Document and markups Документ и маркировки
+ Document and stamps Документ и печати
+ Form fields only Само полета на формуляр
+ Previous sheet Предишен лист
+ Next sheet Следващ лист
+ No sheets Няма листове
+ Sheet {s} of {n} Лист {s} от {n}
+ Removed 1 hidden item. Save to remove them from the file. Премахнат е 1 скрит елемент. Запишете, за да ги премахнете от файла.
+ Removed {n} hidden items. Save to remove them from the file. Премахнати са {n} скрити елемента. Запишете, за да ги премахнете от файла.
+ Document sanitized. Save to finish: saving rewrites the whole file. Документът е почистен. Запишете, за да завършите: записването пренаписва целия файл.
+ Applied 1 redaction mark. Save to remove the content from the file. Приложена е 1 маркировка за заличаване. Запишете, за да премахнете съдържанието от файла.
+ Applied {n} redaction marks. Save to remove the content from the file. Приложени са {n} маркировки за заличаване. Запишете, за да премахнете съдържанието от файла.
+ JavaScript Console Конзола за JavaScript
+ JavaScript is turned off (Preferences ▸ JavaScript). JavaScript е изключен (Настройки > JavaScript).
+ Output appears here. Резултатът се показва тук.
+ Document JavaScripts JavaScript скриптове на документа
+ Script Name: Име на скрипта:
+ This document has no document-level scripts. Този документ няма скриптове на ниво документ.
+ Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Скриптовете се изпълняват в изолирана среда без достъп до файлове и мрежа. При изключен JavaScript стандартните функции на Acrobat за форматиране, проверка и изчисление продължават да работят.
+ No form fields were detected Не са открити полета на формуляр
+ Detected 1 form field Открито е 1 поле на формуляр
+ Detected {n} form fields Открити са {n} полета на формуляр
+ {field}: {e} {field}: {e}
+ Page navigation Навигация по страниците
+ Previous view Предишен изглед
+ Next view Следващ изглед
+ Page display Показване на страниците
+ Continuous scrolling Непрекъснато превъртане
+ Two-page view Изглед с две страници
+ Single page Единична страница
+ Show cover page in two-page view Показвай корицата в изглед с две страници
+ Use system setting Използвай системната настройка
+ Light gray Светлосиво
+ Dark gray Тъмносиво
+ Current page Текуща страница
+ Selected pages Избрани страници
+ Document language Език на документа
+ Output Резултат
+ Searchable Image (Exact) Изображение с възможност за търсене (точно)
+ Adds invisible text over each word; the page image is not changed Добавя невидим текст върху всяка дума; изображението на страницата не се променя
+ Downsample to Намали разделителната способност до
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PRINTCRAFT_MODELS). Разпознаването на текст не е инсталирано: моделите му липсват (изпълнете `cargo xtask models` или задайте PRINTCRAFT_MODELS).
+ Settings Настройки
+ Protect Защита
+ Advanced options Разширени опции
+ Redact Заличаване
+ Optimize Оптимизиране
+ Fields Полета
+ Add form components Добавяне на компоненти на формуляр
+ Stamps Печати
+ Certificates Сертификати
+ Print production Печатна подготовка
+ Measure Измерване
+ Compare Сравняване
+ Actions list Списък с действия
+ Accessibility Достъпност
+ Standards Стандарти
+ Index Индекс
+ Bring your own model (off by default) Използвайте собствен модел (изключено по подразбиране)
+ Single file Един файл
+ Multiple files Няколко файла
+ Images Изображения
+ Clipboard Клипборд
+ Blank page Празна страница
+ Add files to combine Добавете файлове за обединяване
+ Page grid Мрежа от страници
+ Rotate Завърти
+ Insert Вмъкни
+ Replace Замени
+ Split Раздели
+ Set page boxes Задай полета на страницата
+ Page labels Етикети на страниците
+ Page transitions Преходи между страниците
+ Sticky note Бележка
+ Highlight text Маркирай текст
+ Underline Подчертаване
+ Strikethrough Зачертаване
+ Squiggly underline Вълнообразно подчертаване
+ Text box Текстово поле
+ Draw freehand Рисувай свободно
+ Rectangle Правоъгълник
+ Oval Овал
+ Line Линия
+ Arrow Стрелка
+ Cloud Облак
+ Stamp Печат
+ Comment list Списък с коментари
+ Flatten comments Сплескай коментарите
+ In this file В този файл
+ In multiple files В няколко файла
+ Enhance scanned file Подобри сканиран файл
+ Correct recognized text Коригирай разпознатия текст
+ Protect with password Защити с парола
+ Remove hidden information Премахни скритата информация
+ Encrypt with certificate Шифровай със сертификат
+ Security properties Свойства на защитата
+ Redact text and images Заличи текст и изображения
+ Redact pages Заличи страници
+ Find text and redact Намери текст и заличи
+ Set properties Задай свойства
+ Sanitize document Почисти документа
+ Reduce file size Намали размера на файла
+ Advanced optimization Разширена оптимизация
+ Audit space usage Анализ на използваното пространство
+ Field list Списък с полета
+ Field properties Свойства на полето
+ Tab order by rows Ред на табулация по редове
+ Tab order by columns Ред на табулация по колони
+ Flatten form fields Сплескай полетата на формуляра
+ Import data Импортирай данни
+ Export data Експортирай данни
+ Text field Текстово поле
+ Checkbox Отметка (квадратче)
+ Radio button Радиобутон
+ Drop-down list Падащ списък
+ List box Списъчно поле
+ Button Бутон
+ Image field Поле за изображение
+ Date field Поле за дата
+ Digital signature Цифров подпис
+ Stamp palette Палитра с печати
+ Timestamp Времеви печат
+ Validate all signatures Провери всички подписи
+ Certify (visible signature) Сертифицирай (видим подпис)
+ Certify (invisible signature) Сертифицирай (невидим подпис)
+ Output preview Преглед на изхода
+ Preflight Предпечатна проверка
+ Convert colors Преобразувай цветовете
+ Add printer marks Добави печатарски маркери
+ Fix hairlines Поправи тънките линии
+ Measuring tool Инструмент за измерване
+ Geospatial location Геопространствено местоположение
+ Select files to compare Изберете файлове за сравняване
+ Action Wizard Съветник за действия
+ Make accessible Направи достъпен
+ Prepare for distribution Подготви за разпространение
+ Optimize scanned documents Оптимизирай сканирани документи
+ Archive documents Архивирай документи
+ Automatically tag PDF Автоматично тагване на PDF
+ Change reading options Промени опциите за четене
+ Open accessibility report Отвори отчета за достъпност
+ Add alternate text Добави алтернативен текст
+ Fix reading order Поправи реда на четене
+ Manage embedded index Управлявай вградения индекс
+ Console Конзола
+ Document actions Действия на документа
+ Summarize Обобщи
+ Ask about this document Попитай за този документ
+ Translate Преведи
+ Optional По избор
+ Ready Готово
+ Available Налично
+ Planned · {m} Планирано · {m}
+ Planned for milestone {m} — open to see what it will include Планирано за етап {m} — отворете, за да видите какво ще включва
+ Optional: needs an AI provider you configure По избор: изисква доставчик на ИИ, който сами конфигурирате
+ Coming in milestone {m}. Items marked Ready work today. Очаква се в етап {m}. Елементите, отбелязани като готови, работят още днес.
+ 1 mark 1 маркировка
+ {n} marks {n} маркировки
+ Back to all tools Назад към всички инструменти
+ Close panel Затвори панела
+ View more Покажи повече
+ View less Покажи по-малко
+ Edit fields Редактирай полетата
+ Preview Преглед
+ Try the form as people filling it in will see it Изпробвайте формуляра така, както ще го виждат попълващите го
+ Dynamic Динамични
+ Sign Here Подпишете тук
+ Standard Business Стандартни бизнес
+ Choose a stamp, then click on the page to place it. Изберете печат, след което щракнете върху страницата, за да го поставите.
+ Redact all Заличи всичко
+ Clear all Изчисти всичко
+ Add text Добави текст
+ Checkmark Отметка
+ Cross Кръстче
+ Dot Точка
+ Date Дата
+ Signature Подпис
+ Initials Инициали
+ Rotate pages Завърти страници
+ Insert pages Вмъкни страници
+ Extract pages Извлечи страници
+ Extract Извлечи
+ Edit text & images Редактирай текст и изображения
+ Text Текст
+ Image Изображение
+ Header and footer Горен и долен колонтитул
+ Watermark Воден знак
+ Link Връзка
+ Bates numbering Номериране на Бейтс
+ Background Фон
+ Attach file Прикачи файл
+ Export all images Експортирай всички изображения
+ Image (PNG) Изображение (PNG)
+ Text (plain) Текст (обикновен)
+ HTML web page HTML уеб страница
+ Rich Text Format (.rtf) Rich Text Format (.rtf)
+ Comments Коментари
+ Bookmarks Отметки
+ Page thumbnails Миниатюри на страниците
+ Form fields Полета на формуляр
+ Layers Слоеве
+ Attachments Прикачени файлове
+ Signatures Подписи
+ Accessibility Checker Проверка на достъпността
+ Zoom out (⌘−) Намали мащаба (⌘−)
+ Zoom in (⌘+) Увеличи мащаба (⌘+)
+ Rotate view clockwise (⇧⌘+) Завърти изгледа по часовниковата стрелка (⇧⌘+)
+ Toggle fit page / fit width Превключи между „побери страницата“ и „побери по ширина“
+ Next page Следваща страница
+ Previous page Предишна страница
+ Current page — type a page number or label (such as iv) and press Enter Текуща страница — въведете номер или етикет на страница (например iv) и натиснете Enter
+ Open a PDF (⌘O) Отвори PDF файл (⌘O)
+ Dark gray theme Тъмносива тема
+ Light theme Светла тема
+ Print (⌘P) Печат (⌘P)
+ Save (M4) Запиши (M4)
+ Document properties (⌘D) Свойства на документа (⌘D)
+ Action Wizard… Съветник за действия…
+ Add Bates numbering… Добави номериране на Бейтс…
+ Add a button Добави бутон
+ Add a callout Добави изнесена бележка
+ Add a checkbox Добави отметка (квадратче)
+ Add a date field Добави поле за дата
+ Add a digital signature field Добави поле за цифров подпис
+ Add a drop-down list Добави падащ списък
+ Add a list box Добави списъчно поле
+ Add a radio button Добави радиобутон
+ Add a sticky note Добави бележка
+ Add a text box Добави текстово поле
+ Add a text field Добави текстово поле за попълване
+ Add alternate text… Добави алтернативен текст…
+ Add an image field Добави поле за изображение
+ Add image… Добави изображение…
+ Add or edit links Добави или редактирай връзки
+ Apply redactions… Приложи заличаванията…
+ Attach a file Прикачи файл
+ Change reading options… Промени опциите за четене…
+ Check for accessibility Провери за достъпност
+ Clear redaction marks Изчисти маркировките за заличаване
+ Compare files… Сравни файлове…
+ Create links from URLs Създай връзки от URL адреси
+ Detect form fields Открий полета на формуляр
+ Draw a cloud Нарисувай облак
+ Draw a line Нарисувай линия
+ Draw a polygon Нарисувай многоъгълник
+ Draw a rectangle Нарисувай правоъгълник
+ Draw an arrow Нарисувай стрелка
+ Draw an oval Нарисувай овал
+ Draw connected lines Нарисувай свързани линии
+ Erase drawings Изтрий рисунките
+ Export all comments to data file… Експортирай всички коментари във файл с данни…
+ Export form data… Експортирай данните от формуляра…
+ Field properties… Свойства на полето…
+ Fill & Sign: add text Попълни и подпиши: добави текст
+ Fill & Sign: checkmark Попълни и подпиши: отметка
+ Fill & Sign: cross Попълни и подпиши: кръстче
+ Fill & Sign: date Попълни и подпиши: дата
+ Fill & Sign: dot Попълни и подпиши: точка
+ Fill & Sign: initials Попълни и подпиши: инициали
+ Fill & Sign: line Попълни и подпиши: линия
+ Fill & Sign: sign Попълни и подпиши: подпиши
+ Find text and redact… Намери текст и заличи…
+ Hide all comments Скрий всички коментари
+ Import comments… Импортирай коментари…
+ Import form data… Импортирай данни за формуляр…
+ Insert text Вмъкни текст
+ Merge data files into spreadsheet… Обедини файлове с данни в електронна таблица…
+ Optimize scanned documents… Оптимизирай сканирани документи…
+ Prepare for distribution… Подготви за разпространение…
+ Recognize text in multiple files… Разпознай текст в няколко файла…
+ Redact pages… Заличи страници…
+ Redaction properties… Свойства на заличаването…
+ Remove all links Премахни всички връзки
+ Remove background Премахни фона
+ Remove header & footer Премахни горния и долния колонтитул
+ Remove hidden information… Премахни скритата информация…
+ Remove watermark Премахни водния знак
+ Replace text Замени текст
+ Sanitize document… Почисти документа…
+ Squiggly underline text Подчертай текст с вълнообразна линия
+ Strikethrough text Зачертай текст
+ Summarize comments Обобщи коментарите
+ Tab order: by columns Ред на табулация: по колони
+ Tab order: by document structure Ред на табулация: по структурата на документа
+ Tab order: by rows Ред на табулация: по редове
+ Underline text Подчертай текст
+ Update background… Актуализирай фона…
+ Update header & footer… Актуализирай горния и долния колонтитул…
+ Update watermark… Актуализирай водния знак…
+ Note Бележка
+ Polyline Начупена линия
+ Attachment Прикачен файл
+ Callout Изнесена бележка
+ Typewriter Пишеща машина
+ Checked Отметнато
+ Unchecked Неотметнато
+ Marked Маркирано
+ Locked Заключено
+ Search comments Търсене в коментарите
+ Add a comment Добави коментар
+ No comments yet. Все още няма коментари.
+ Pick a tool in the quick bar, or type above. Изберете инструмент от бързата лента или пишете по-горе.
+ No comments match. Няма съвпадащи коментари.
+ Unknown author Неизвестен автор
+ No colour Без цвят
+ Remove checkmark Премахни отметката
+ Mark with checkmark Отбележи с отметка
+ Reply Отговор
+ Add a reply Добави отговор
+ Post Публикувай
+ Page {label} Страница {label}
+ {kind} — click to show it on the page {kind} — щракнете, за да го покажете на страницата
+ Set status Задай състояние
+ Copy text Копирай текста
+ Properties… Свойства…
+ Make Current Properties Default Направи текущите свойства подразбиращи се
+ More options Още опции
+ Sort by Сортиране по
+ Author Автор
+ Type Тип
+ Colour Цвят
+ Show all comments Покажи всички коментари
+ Create PDF comment summary… Създай обобщение на коментарите в PDF…
+ Export all to data file… Експортирай всички във файл с данни…
+ Filter comments Филтрирай коментарите
+ Status Състояние
+ Accepted Прието
+ Rejected Отхвърлено
+ Cancelled Отменено
+ Completed Завършено
+ Show all Покажи всички
+ Yellow Жълто
+ Orange Оранжево
+ Red Червено
+ Pink Розово
+ Purple Лилаво
+ Blue Синьо
+ Light blue Светлосиньо
+ Green Зелено
+ Gray Сиво
+ Black Черно
+ Search Търсене
+ Bookmark options Опции за отметки
+ Expand all bookmarks Разгъни всички отметки
+ Expand top-level bookmarks Разгъни отметките от първо ниво
+ Collapse all bookmarks Свий всички отметки
+ New bookmark (⌘B) Нова отметка (⌘B)
+ This document has no bookmarks. Този документ няма отметки.
+ This document has no layers. Този документ няма слоеве.
+ This document has no attachments. Този документ няма прикачени файлове.
+ Hide layer Скрий слоя
+ Show layer Покажи слоя
+ Set layer visibility Задай видимост на слоя
+ on page {label} на страница {label}
+ Save attachment… Запиши прикачения файл…
+ Open in a new tab Отвори в нов раздел
+ This document has no pages. Този документ няма страници.
+ Find text Намери текст
+ No matches Няма съвпадения
+ Searching… {s}/{p} Търсене… {s}/{p}
+ {c} of {n}{more} {c} от {n}{more}
+ Previous (⇧⌘G) Предишно (⇧⌘G)
+ Next (⌘G) Следващо (⌘G)
+ Find options Опции за търсене
+ Whole words only Само цели думи
+ Case-sensitive Чувствително към регистъра
+ Close (Esc) Затвори (Esc)
+ Align Подравни
+ Top Горе
+ Bottom Долу
+ Center horizontally Центрирай хоризонтално
+ Center vertically Центрирай вертикално
+ Distribute Разпредели
+ Horizontally Хоризонтално
+ Vertically Вертикално
+ Set Fields to Same Size Задай еднакъв размер на полетата
+ Height Височина
+ Width Ширина
+ Both И двете
+ Duplicate… Дублирай…
+ All pages Всички страници
+ Odd pages Нечетни страници
+ Even pages Четни страници
+ Landscape pages Пейзажни страници
+ Portrait pages Портретни страници
+ Move earlier Премести по-напред
+ Move later Премести по-назад
+ Back to the document Обратно към документа
+ Protect Using Password Защита с парола
+ Requires user to enter a password for: Изисква от потребителя да въведе парола за:
+ Viewing Преглед
+ Editing Редактиране
+ Type Password Въведете парола
+ Re-type Password Повторете паролата
+ Strength: {s} Надеждност: {s}
+ Strong Силна
+ Medium Средна
+ Weak Слаба
+ Advanced Options {chevron} Разширени опции {chevron}
+ Compatibility Съвместимост
+ Acrobat X and later (256-bit AES) Acrobat X и по-нови (256-битов AES)
+ Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 и по-нови (128-битов AES)
+ Acrobat 5.0 and later (128-bit RC4) Acrobat 5.0 и по-нови (128-битов RC4)
+ Acrobat 3.0 and later (40-bit RC4) Acrobat 3.0 и по-нови (40-битов RC4)
+ Encrypt Шифровай
+ All document contents Цялото съдържание на документа
+ All document contents except metadata Цялото съдържание на документа без метаданните
+ Printing allowed Разрешен печат
+ Low Resolution (150 dpi) Ниска разделителна способност (150 dpi)
+ Changes allowed Разрешени промени
+ Inserting, deleting, and rotating pages Вмъкване, изтриване и завъртане на страници
+ Filling in form fields and signing existing signature fields Попълване на полета на формуляр и подписване в съществуващи полета за подпис
+ Commenting, filling in form fields, and signing existing signature fields Коментиране, попълване на полета на формуляр и подписване в съществуващи полета за подпис
+ Any except extracting pages Всички без извличане на страници
+ Enable copying of text, images, and other content Разреши копиране на текст, изображения и друго съдържание
+ Enable text access for screen reader devices for the visually impaired Разреши достъп до текста за екранни четци за хора със зрителни увреждания
+ Type a password. Въведете парола.
+ The passwords don't match. Паролите не съвпадат.
+ This compatibility level supports only plain ASCII passwords. Това ниво на съвместимост поддържа само пароли с обикновени ASCII знаци.
+ Anyone opening the document will need this password. It's applied when you save. Всеки, който отваря документа, ще се нуждае от тази парола. Тя се прилага при записване.
+ The document opens without a password; this password is needed to change the restrictions. It's applied when you save. Документът се отваря без парола; тази парола е нужна за промяна на ограниченията. Тя се прилага при записване.
+ Recover unsaved documents? Да се възстановят ли незаписаните документи?
+ PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft не беше затворен нормално. Тези документи имат автоматично записани промени:
+ just now току-що
+ {n} min ago преди {n} мин
+ {n} h ago преди {n} ч
+ · password-protected · защитен с парола
+ Recover Възстанови
+ Discard Отхвърли
+ Next / previous match Следващо / предишно съвпадение
+ Copy selected text Копирай избрания текст
+ Select a word Избери дума
+ Clear selection / close find Изчисти избора / затвори търсенето
+ Zoom in / out (also pinch or ⌘-scroll) Увеличи / намали (също с щипване или ⌘ + превъртане)
+ Rotate view Завърти изгледа
+ First / last page Първа / последна страница
+ Previous / next page Предишна / следваща страница
+ Delete selected pages (Organize) Изтрий избраните страници (Организиране)
+ Select all pages (Organize) Избери всички страници (Организиране)
+ Version {v} Версия {v}
+ A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. Независимо разработено PDF приложение с отворен код, написано на Rust. MIT OR Apache-2.0.
+ Part of Част от
+ Summarize Options Опции за обобщение
+ Choose a layout: Изберете оформление:
+ Comments only Само коментари
+ Sort comments by: Сортирай коментарите по:
+ Create PDF Comment Summary Създай PDF обобщение на коментарите
+ Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Да се върне ли последната записана версия на „{name}“? Промените след нея няма да могат да бъдат отменени.
+ Number pages Номерирай страниците
+ Style Стил
+ None (prefix only) Без (само префикс)
+ Prefix Префикс
+ Start Начало
+ Labels: {preview}. Later pages keep their labels. Етикети: {preview}. Следващите страници запазват етикетите си.
+ Even and Odd Pages Четни и нечетни страници
+ Even Pages Only Само четни страници
+ Odd Pages Only Само нечетни страници
+ Landscape and Portrait Pages Пейзажни и портретни страници
+ Landscape Pages Пейзажни страници
+ Portrait Pages Портретни страници
+ Duplicate Field Дублиране на поле
+ Duplicate "{name}" onto: Дублирай „{name}“ върху:
+ 1 page selected. Избрана е 1 страница.
+ {n} pages selected. Избрани са {n} страници.
+ Delete pages after extracting Изтрий страниците след извличане
+ Extract pages as separate files Извлечи страниците като отделни файлове
+ Rotate Pages Завъртане на страници
+ Direction: Посока:
+ Clockwise 90 degrees По часовниковата стрелка на 90 градуса
+ Counterclockwise 90 degrees Обратно на часовниковата стрелка на 90 градуса
+ 180 degrees 180 градуса
+ Pages: Страници:
+ All Всички
+ Selection Избраните
+ From От
+ Rotate: Завърти:
+ Repairs Поправки
+ Repair log Дневник на поправките
+ Split document Разделяне на документ
+ Number of pages Брой страници
+ Pages per file Страници на файл
+ File size Размер на файла
+ At most Най-много
+ Top-level bookmarks ({n}) Отметки от най-горно ниво ({n})
+ Before each selected page (select pages in Organize) Преди всяка избрана страница (изберете страници в Организиране)
+ Creates 1 file from {n} pages. Създава 1 файл от {n} страници.
+ Creates {f} files from {n} pages. Създава {f} файла от {n} страници.
+ Each file holds as many of the {n} pages as fit. Всеки файл съдържа толкова от общо {n} страници, колкото се поберат.
+ Replace Pages Замяна на страници
+ Original: replace pages Оригинал: замени страниците
+ to до
+ to {n} до {n}
+ Replacement: pages of {name} Заместител: страници от {name}
+ of {name} от {name}
+ Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. Променя се само съдържанието на страниците: връзките, коментарите, полетата на формуляра и отметките на оригиналните страници остават.
+ The replacement file doesn't have that many pages. Заместващият файл няма толкова страници.
+ Tagged PDF PDF с етикети
+ Document Properties Свойства на документа
+ Description Описание
+ Initial View Начален изглед
+ Security Защита
+ Fonts Шрифтове
+ Advanced Разширени
+ Title Заглавие
+ Subject Тема
+ Keywords Ключови думи
+ Application Приложение
+ PDF producer PDF генератор
+ Layout and Magnification Оформление и мащаб
+ Navigation tab Навигационен раздел
+ Page Only Само страница
+ Bookmarks Panel and Page Панел „Отметки“ и страница
+ Pages Panel and Page Панел „Страници“ и страница
+ Attachments Panel and Page Панел „Прикачени файлове“ и страница
+ Layers Panel and Page Панел „Слоеве“ и страница
+ Page layout Оформление на страниците
+ Default По подразбиране
+ Single Page Единична страница
+ Single Page Continuous Единична страница, непрекъснато
+ Two-Up (Facing) Две страници (една срещу друга)
+ Two-Up Continuous (Facing) Две страници, непрекъснато (една срещу друга)
+ Two-Up (Cover Page) Две страници (с корица)
+ Two-Up Continuous (Cover Page) Две страници, непрекъснато (с корица)
+ Magnification Мащаб
+ Actual Size Действителен размер
+ Fit Page Побери страницата
+ Fit Width Побери по ширина
+ Fit Height Побери по височина
+ Fit Visible Побери видимото
+ Open to page Отвори на страница
+ of {pages} от {pages}
+ Window Options Опции на прозореца
+ Resize window to initial page Преоразмери прозореца спрямо началната страница
+ Center window on screen Центрирай прозореца на екрана
+ Open in Full Screen mode Отвори в режим на цял екран
+ Show: Показвай:
+ File Name Име на файла
+ Document Title Заглавие на документа
+ User Interface Options Опции на потребителския интерфейс
+ Hide menu bar Скрий лентата с менюта
+ Hide toolbars Скрий лентите с инструменти
+ Hide window controls Скрий контролите на прозореца
+ Security method Метод на защита
+ No security Без защита
+ Restrictions Ограничения
+ None — everything is allowed Няма — всичко е разрешено
+ Password security Защита с парола
+ Encryption Шифроване
+ Opened with Отворен с
+ — (protection is applied when you save) — (защитата се прилага при записване)
+ Owner password (no restrictions apply) Парола на собственика (без ограничения)
+ User password Потребителска парола
+ Change settings… Промени настройките…
+ Printing Печат
+ Not allowed Не е разрешено
+ Allowed Разрешено
+ High resolution Висока разделителна способност
+ Low resolution Ниска разделителна способност
+ Changing the document Промяна на документа
+ Document assembly Сглобяване на документа
+ Content copying Копиране на съдържание
+ Content copying for accessibility Копиране на съдържание за достъпност
+ Commenting Коментиране
+ Filling of form fields Попълване на полета на формуляр
+ No fonts are referenced by the pages. Страниците не използват шрифтове.
+ PDF version Версия на PDF
+ Location Местоположение
+ Page size Размер на страницата
+ Yes Да
+ No Не
+ Revisions Редакции
+ View revision {n} Покажи редакция {n}
+ Open the file as it was saved then Отвори файла във вида, в който е записан тогава
+ Binding Подвързване
+ Left Edge Ляв ръб
+ Right Edge Десен ръб
+ Language Език
+ Saved {place}: {before} → {after} ({pct}% smaller){detail} Записано {place}: {before} → {after} (с {pct}% по-малко){detail}
+ Couldn't save the image: {e} Изображението не можа да бъде записано: {e}
+ Adding images arrives on the web with file pickers for images Добавянето на изображения в уеб версията ще стане достъпно с инструментите за избор на файлове с изображения
+ Replacing images on page {p} arrives on the web with image pickers ({i}) Замяната на изображения на страница {p} в уеб версията ще стане достъпна с инструментите за избор на изображения ({i})
+ {name}: choosing images arrives on the web with file pickers for images {name}: изборът на изображения в уеб версията ще стане достъпен с инструментите за избор на файлове с изображения
+ Replacing images arrives on the web with image file pickers Замяната на изображения в уеб версията ще стане достъпна с инструментите за избор на файлове с изображения
+ Couldn't create a PDF: {e} Не можа да се създаде PDF файл: {e}
+ The clipboard has no image or text В клипборда няма изображение или текст
+ On the web, open or drop an image to convert it В уеб версията отворете или пуснете изображение, за да го конвертирате
+ Couldn't optimize the file: {e} Файлът не можа да бъде оптимизиран: {e}
+ Couldn't write {path}: {e} Не можа да се запише {path}: {e}
+ The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. Формулярът изисква да бъде изпратен до {u}; PdfCraft не изпраща данни от формуляри. Запишете документа, за да запазите въведеното.
+ {s} actions run in the JavaScript engine (M6) Действията {s} се изпълняват в JavaScript машината (M6)
+ {name}: the {other} action isn't supported yet {name}: действието {other} все още не се поддържа
+ {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} изпраща формуляра до {url}; PdfCraft не изпраща данни от формуляри. Запишете документа, за да запазите въведеното.
+ Export stopped: {e} Експортирането е спряно: {e}
+ Exported 1 image Експортирано е 1 изображение
+ Exported {n} images Експортирани са {n} изображения
+ ({s} not exported: {first}) ({s} не са експортирани: {first})
+ Exported the text of 1 page Експортиран е текстът на 1 страница
+ Exported the text of {n} pages Експортиран е текстът на {n} страници
+ {msg} to {dir} {msg} в {dir}
+ Exporting… {d} of {t} Експортиране… {d} от {t}
+ Choose a folder for the exported files Изберете папка за експортираните файлове
+ New {tool} comments will look like this one Новите коментари от тип {tool} ще изглеждат като този
+ This document can't be checked: it couldn't be read for editing Този документ не може да бъде проверен: не можа да бъде прочетен за редактиране
+ The checker found no problems in this document. Проверката не откри проблеми в този документ.
+ The checker found 1 problem which may prevent the document from being fully accessible. Проверката откри 1 проблем, който може да попречи документът да бъде напълно достъпен.
+ The checker found {n} problems which may prevent the document from being fully accessible. Проверката откри {n} проблема, които могат да попречат документът да бъде напълно достъпен.
+ The text is the same Текстът е същият
+ 1 difference found Открита е 1 разлика
+ {n} differences found Открити са {n} разлики
+ Added 1 comment Добавен е 1 коментар
+ Added {n} comments Добавени са {n} коментара
+ Choose a folder for the results Изберете папка за резултатите
+ Files for {name} Файлове за {name}
+ An action is already running Вече се изпълнява действие
+ Running action… file {d} of {t} Изпълнение на действие… файл {d} от {t}
+ No unlinked web addresses were found in the text В текста не са открити уеб адреси без връзка
+ Created 1 link Създадена е 1 връзка
+ Created {n} links Създадени са {n} връзки
+ Text recognition is already running Разпознаването на текст вече се изпълнява
+ Optimizing… removing user data Оптимизиране… премахване на потребителски данни
+ Optimizing… image {d} of {t} Оптимизиране… изображение {d} от {t}
+ Optimizing… cleaning up Оптимизиране… почистване
+ Optimizing… merging identical objects Оптимизиране… сливане на еднакви обекти
+ Optimizing… writing the copy Оптимизиране… записване на копието
+ Cancelling… Отказване…
+ Optimization is already running Оптимизирането вече се изпълнява
+ Optimization cancelled Оптимизирането е отказано
+ Couldn't optimize the file Файлът не можа да бъде оптимизиран
+ Recognizing text… file {d} of {t} Разпознаване на текст… файл {d} от {t}
+ Recognizing text… page {d} of {t} Разпознаване на текст… страница {d} от {t}
+ Recognized 1 word Разпозната е 1 дума
+ Recognized {n} words Разпознати са {n} думи
+ {words} on {pages} {words} на {pages}
+ ; 1 page already had text ; 1 страница вече съдържаше текст
+ ; {n} pages already had text ; {n} страници вече съдържаха текст
+ Recognized {w} words in 1 file Разпознати са {w} думи в 1 файл
+ Recognized {w} words in {n} files Разпознати са {w} думи в {n} файла
+ ; failed: {list} ; неуспешни: {list}
+ 1 file done Обработен е 1 файл
+ {n} files done Обработени са {n} файла
+ The selected area has been copied ({w} × {h} pixels) Избраната област е копирана ({w} × {h} пиксела)
+ Couldn't take the snapshot: {e} Не можа да се направи моментна снимка: {e}
+ Couldn't recover {name}: {e} {name} не можа да бъде възстановен: {e}
+ The document now conforms to {level}; save it to keep the changes Документът вече отговаря на {level}; запишете го, за да запазите промените
+ Fixed what could be fixed; 1 problem remains Поправено е всичко възможно; остава 1 проблем
+ Fixed what could be fixed; {n} problems remain Поправено е всичко възможно; остават {n} проблема
+ Custom stamps arrive on the web with file pickers for images Потребителските печати в уеб версията ще станат достъпни с инструментите за избор на файлове с изображения
+ {file} is too large for a stamp (at most {n} MB) {file} е твърде голям за печат (най-много {n} MB)
+ Sent to {printer} Изпратено до {printer}
+ Sent to {printer}: {msg} Изпратено до {printer}: {msg}
+ Choose a folder for the searchable files Изберете папка за файловете с възможност за търсене
+ Files are combined from top to bottom. Leave Pages empty to take every page. Файловете се обединяват отгоре надолу. Оставете „Страници“ празно, за да се вземат всички страници.
+ Unlock… Отключи…
+ Select a password-protected file first Първо изберете файл, защитен с парола
+ Enter the permissions password to allow combining Въведете паролата за разрешения, за да позволите обединяването
+ Enter the password that opens it Въведете паролата за отваряне
+ Unlock {name} Отключи {name}
+ Unlock {n} files Отключи {n} файла
+ The same password is tried on each file. Една и съща парола се опитва за всеки файл.
+ Hide password Скрий паролата
+ Show password Покажи паролата
+ Wrong password Грешна парола
+ The password is only used to read the file; it isn't saved. Паролата се използва само за четене на файла; тя не се запазва.
+ Unlock Отключи
+ Opened, but its security settings don't allow combining. Enter the permissions password. Файлът е отворен, но настройките му за защита не позволяват обединяване. Въведете паролата за разрешения.
+ Unlocked: the combined file won't be password-protected Отключен: обединеният файл няма да бъде защитен с парола
+ Unlocked {n} of {m} files Отключени са {n} от {m} файла
+ Drop PDFs or folders here, or choose them. Пуснете тук PDF файлове или папки или ги изберете.
+ Add folder… Добави папка…
+ Add folder and subfolders… Добави папка и подпапки…
+ More ways to add files Още начини за добавяне на файлове
+ Every PDF in the folder Всички PDF файлове в папката
+ Every PDF in the folder and the folders inside it Всички PDF файлове в папката и в папките в нея
+ Add folder Добави папка
+ Add folder and subfolders Добави папка и подпапки
+ No PDFs found in {folder} Не са открити PDF файлове в {folder}
+ Added the first {n} PDFs in {folder} Добавени са първите {n} PDF файла от {folder}
+ Click to sort, drag to move the column. Drag its edge to resize it. Щракнете за сортиране, плъзнете, за да преместите колоната. Плъзнете ръба на колоната, за да промените размера.
+ Reset columns Нулирай колоните
+ Remove {n} files Премахни {n} файла
+ {n} selected Избрани: {n}
+ sorted ascending сортирано във възходящ ред
+ sorted descending сортирано в низходящ ред
+ Protected: the combined file won't keep its security settings Защитен: обединеният файл няма да запази настройките за защита
+ Add open documents Добави отворените документи
+ All open documents Всички отворени документи
+ No documents are open Няма отворени документи
+ Select a file first Първо изберете файл
+ 1 file 1 файл
+ {n} files {n} файла
+ 1 file needs attention 1 файл изисква внимание
+ {n} files need attention {n} файла изискват внимание
+ {name} can't be combined: {why} {name} не може да бъде обединен: {why}
+ Check the pages of {name}: {why} Проверете страниците на {name}: {why}
+ Add the PDFs to combine Добавете PDF файловете за обединяване
+ File name Име на файла
+ Warnings Предупреждения
+ Drag to reorder Плъзнете, за да пренаредите
+ {k} of {n} {k} от {n}
+ ? of {n} ? от {n}
+ Added more than once Добавен повече от веднъж
+ yesterday вчера
+ {n} days ago преди {n} дни
+ No pages selected Няма избрани страници
+ Password-protected Защитен с парола
+ Its security settings don't allow copying pages Настройките му за защита не позволяват копиране на страници
+ Can't be read: {e} Не може да бъде прочетен: {e}
+ Includes unsaved changes Съдържа незаписани промени
+ Pages of {name} to combine, e.g. 1-3, 6 Страници от {name} за обединяване, напр. 1-3, 6
+ Remove {name} Премахни {name}
+ Add files… Добави файлове…
+ Add at least two files Добавете поне два файла
+ Couldn't add {name}: {e} {name} не можа да бъде добавен: {e}
+ Couldn't combine files: {e} Файловете не можаха да бъдат обединени: {e}
+ Export comments Експортирай коментарите
+ Export form data Експортирай данните от формуляра
+ Comment and form data Данни от коментари и формуляри
+ Export Експортирай
+ Select data files to merge Изберете файлове с данни за сливане
+ Form data and PDF forms Данни от формуляри и PDF формуляри
+ Save the spreadsheet Запиши електронната таблица
+ Save the accessibility report Запиши отчета за достъпност
+ Save image Запиши изображението
+ Export certificate Експортирай сертификата
+ Choose a folder for the extracted pages Изберете папка за извлечените страници
+ Choose a folder for the split files Изберете папка за разделените файлове
+ Couldn't use {name}: {e} {name} не можа да бъде използван: {e}
+ Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e}
+ Couldn't write {name}: {e} {name} не можа да бъде записан: {e}
+ Wrote 1 file to {dir} Записан е 1 файл в {dir}
+ Wrote {n} files to {dir} Записани са {n} файла в {dir}
+ No pages match those choices Няма страници, отговарящи на този избор
+ Couldn't split: {e} Разделянето не бе успешно: {e}
+ Couldn't summarize comments: {e} Коментарите не можаха да бъдат обобщени: {e}
+ Couldn't open the result: {e} Резултатът не можа да бъде отворен: {e}
+ Importing data arrives on the web with file pickers for data files Импортирането на данни в уеб версията ще стане достъпно с инструментите за избор на файлове с данни
+ Exported to {path} Експортирано в {path}
+ Merging data files needs the desktop app Сливането на файлове с данни изисква настолното приложение
+ Merged 1 file into {path} Слят е 1 файл в {path}
+ Merged {n} files into {path} Слети са {n} файла в {path}
+ Combined {n} files Обединени са {n} файла
+ Extracted 1 page Извлечена е 1 страница
+ Extracted {n} pages Извлечени са {n} страници
+ Created a comment summary Създадено е обобщение на коментарите
+ Couldn't open {name}: {e} {name} не можа да бъде отворен: {e}
+ Couldn't read {name}: {e} {name} не можа да бъде прочетен: {e}
+ Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Преносимият режим е изключен: в {folder} не може да се записва ({error}). Вместо това настройките се пазят в потребителската ви папка.
+ Couldn't save: {e} Записването не бе успешно: {e}
+ Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Изтеглянето на прикачени файлове в уеб версията ще стане достъпно с M3.10 ({n} байта готови)
+ `{command}` {when} `{command}` {when}
+ ships in milestone {m} ще бъде включено в етап {m}
+ needs an AI provider (off by default) изисква доставчик на ИИ (изключено по подразбиране)
+ is available е налично
+ is not available yet все още не е налично
+ {label} failed: {e} {label} — неуспешно: {e}
+ Undid {label} Отменено: {label}
+ Redid {label} Повторено: {label}
+ A document needs at least one page: copy instead Документът трябва да има поне една страница: използвайте копиране
+ Cut {what} Изрязано: {what}
+ Copied {what} Копирано: {what}
+ 1 page 1 страница
+ {n} pages {n} страници
+ Copy or cut pages first Първо копирайте или изрежете страници
+ Couldn't save {name}: {e} {name} не можа да бъде записан: {e}
+ Saved {name} Записан: {name}
+ Saved, but reopening failed: {e} Записано, но повторното отваряне не бе успешно: {e}
+ Downloaded {name} Изтеглен: {name}
+ Couldn't download {name}: {e} {name} не можа да бъде изтеглен: {e}
+ Reverted to the last saved version Възстановена е последната записана версия
+ That didn't work: an internal error stopped it ({m}). Your documents are unchanged. Действието не бе успешно: прекъсна го вътрешна грешка ({m}). Документите ви не са променени.
+ Nothing to undo Няма какво да се отмени
+ Nothing to redo Няма какво да се повтори
+ This document has no form fields you can fill in Този документ няма полета на формуляр, които могат да се попълнят
+ This document has no comments to flatten Този документ няма коментари за сплескване
+ This document has no form fields to flatten Този документ няма полета на формуляр за сплескване
+ There are no redaction marks (mark text, areas or pages first) Няма маркировки за заличаване (първо маркирайте текст, области или страници)
+ This document has no {kind} to change Този документ няма {kind} за промяна
+ header or footer горен или долен колонтитул
+ watermark воден знак
+ background фон
+ This document isn't password-protected Този документ не е защитен с парола
+ Only the document's owner can change its security (open it with the permissions password) Само собственикът на документа може да промени защитата му (отворете го с паролата за разрешения)
+ The document's security settings don't allow this change Настройките за защита на документа не позволяват тази промяна
+ Open a document first Първо отворете документ
+ Security will be removed when you save Защитата ще бъде премахната при записване
+ Click text or an image to edit it Щракнете върху текст или изображение, за да го редактирате
+ Drag a rectangle to create a link; double-click a link to edit it Очертайте правоъгълник, за да създадете връзка; щракнете двукратно върху връзка, за да я редактирате
+ There are no redaction marks to apply Няма маркировки за заличаване, които да бъдат приложени
+ Drag to draw the area where the signature should appear. Плъзнете, за да очертаете областта, където да се появи подписът.
+ Couldn't fit the visible content: {e} Видимото съдържание не може да бъде побрано: {e}
+ Drag a rectangle around the area to copy Очертайте правоъгълник около областта за копиране
+ Click on the page to add a {tool}, or drag to set its size Щракнете върху страницата, за да добавите {tool}, или плъзнете, за да зададете размера
+ Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes Очертайте правоъгълник върху страница, за да я изрежете; щракнете двукратно върху страница за „Задай полета на страницата“
+ Keep tool selected: on Запази инструмента избран: вкл.
+ Keep tool selected Запази инструмента избран
+ Opacity Непрозрачност
+ Line thickness Дебелина на линията
+ What word or phrase would you like to search for? Каква дума или фраза искате да търсите?
+ Searching… {s} of {p} pages Търсене… {s} от {p} страници
+ (searching… {s} of {p} pages) (търсене… {s} от {p} страници)
+ 1 instance 1 съвпадение
+ {n} instances {n} съвпадения
+ Go to this result Към този резултат
+ Highlight Маркиране
+ Draw Рисуване
+ Polygon Многоъгълник
+ Connected lines Свързани линии
+ Eraser Гумичка
+ Sign Подпиши
+ Certified by {by} Сертифициран от {by}
+ At least one signature is invalid. Поне един подпис е невалиден.
+ Signed and all signatures are valid. Подписан, всички подписи са валидни.
+ At least one signature has problems. Поне един подпис има проблеми.
+ This document is secured. Some changes are restricted by its security settings. Този документ е защитен. Някои промени са ограничени от настройките му за защита.
+ This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. Това е динамичен XFA формуляр, който PdfCraft все още не може да показва. Виждате заместващата страница на файла.
+ This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. Този формуляр съдържа и XFA данни, които PdfCraft все още не чете. Можете да попълвате полетата му, но Acrobat може да покаже вместо това XFA стойностите.
+ This document contains {n} interactive form fields. Този документ съдържа {n} интерактивни полета на формуляр.
+ This file was damaged and has been repaired. Saving keeps the repaired version. Този файл беше повреден и е поправен. При записване се запазва поправената версия.
+ Dismiss Скрий
+ Hide field highlights Скрий маркирането на полетата
+ Highlight fields Маркирай полетата
+ Security settings Настройки за защита
+ Details Подробности
+ Select (V) Избиране (V)
+ Hand (H) Ръка (H)
+ Delete pages (Delete) Изтрий страниците (Delete)
+ Insert a blank page after the selection Вмъкни празна страница след избраното
+ Insert pages from a file… Вмъкни страници от файл…
+ Extract pages to a new document Извлечи страниците в нов документ
+ Split into files… Раздели на файлове…
+ Select pages Избери страници
+ Cut Изрежи
+ Copy Копирай
+ Paste after Постави след
+ Rename Преименувай
+ Set to current page ({n}) Задай текущата страница ({n})
+ Move up Премести нагоре
+ Move down Премести надолу
+ Indent Увеличи отстъпа
+ Outdent Намали отстъпа
+ This document has no form fields. Този документ няма полета на формуляр.
+ Tab order: move a field with its arrows. Ред на обхождане: преместете поле със стрелките му.
+ Unplaced Неразположени
+ Has JavaScript actions (run in M6) Има JavaScript действия (изпълняват се в M6)
+ Page Страница
+ None Няма
+ Tool Инструмент
+ Command Команда
+ Search tools and commands… Търсене на инструменти и команди…
+ No matching tools Няма съответстващи инструменти
+ All tools Всички инструменти
+ Read Четене
+ Convert Конвертиране
+ E-Sign Е-подпис
+ Welcome to PdfCraft Добре дошли в PdfCraft
+ An open-source PDF workbench — local, private, and scriptable. PDF работна среда с отворен код — локална, поверителна и програмируема.
+ Recommended tools Препоръчани инструменти
+ Use now Използвай сега
+ Recent Последни
+ Privacy Поверителност
+ Export to image… Експортирай като изображение…
+ Reduce file size… Намали размера на файла…
+ Optimize PDF… Оптимизирай PDF…
+ Export to text… Експортирай като текст…
+ Export to Word… Експортирай в Word…
+ Export to HTML… Експортирай в HTML…
+ Export to RTF… Експортирай в RTF…
+ Export all images… Експортирай всички изображения…
+ Protect using password… Защити с парола…
+ Remove security Премахни защитата
+ Security properties… Свойства на защитата…
+ Organize pages Организиране на страници
+ New bookmark Нова отметка
+ Rotate pages clockwise Завърти страниците по часовниковата стрелка
+ Rotate pages counterclockwise Завърти страниците обратно на часовниковата стрелка
+ Delete pages Изтрий страници
+ Insert blank page Вмъкни празна страница
+ Rotate pages… Завърти страници…
+ Duplicate pages Дублирай страници
+ Crop pages Изрежи страници
+ Set page boxes… Задай полета на страницата…
+ Insert pages from file… Вмъкни страници от файл…
+ Replace pages… Замени страници…
+ Extract pages… Извлечи страници…
+ Split document… Раздели документа…
+ Number pages… Номерирай страниците…
+ Keyboard shortcuts Клавишни комбинации
+ Join the ArtCraft Discord Присъедини се към ArtCraft в Discord
+ Join the ArtCraft community Присъедини се към общността на ArtCraft
+ Get help, share feedback and follow development on Discord. Получете помощ, споделете мнение и следете разработката в Discord.
+ Join our Discord Присъедини се към нашия Discord
+ PdfCraft web page Уебстраница на PdfCraft
+ PdfCraft on GitHub PdfCraft в GitHub
+ ArtCraft website Уебсайт на ArtCraft
+ Check for updates… Провери за актуализации…
+ About PdfCraft За PdfCraft
+ JavaScript console… JavaScript конзола…
+ Document JavaScripts… JavaScript на документа…
+ Recognize text… Разпознай текст…
+ Check for accessibility… Провери достъпността…
+ Add header & footer… Добави горен и долен колонтитул…
+ Add watermark… Добави воден знак…
+ Add background… Добави фон…
+ Prepare a form Подготви формуляр
+ Digitally sign Подпиши цифрово
+ Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Файловете, които отваряте в PdfCraft, се показват тук. Пуснете PDF файл където и да е, за да го отворите.
+ PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft работи офлайн. Без телеметрия, без акаунт и без обработка в облака, освен ако не добавите доставчик.
+ Measure distance Измери разстояние
+ Measure perimeter Измери периметър
+ Measure area Измери площ
+ Set measurement scale Задай мащаб на измерване
+ Measurement information Информация за измерването
+ Measurement snapping Прилепване при измерване
+ Export measurements as CSV Експортирай измерванията като CSV
+ Distance Разстояние
+ Perimeter Периметър
+ Area Площ
+ Drawing scale Мащаб на чертежа
+ Snapping Прилепване
+ Export measurements Експортирай измерванията
+ Open a PDF to measure it. Отворете PDF файл, за да го измервате.
+ Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. Щракнете върху върховете. Enter или двукратно щракване завършва периметър или площ. Щракнете върху първата точка, за да затворите площ.
+ Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift ограничава до 45 градуса. Backspace премахва връх. Escape отменя.
+ Angle Ъгъл
+ Click the first point to begin. Щракнете върху първата точка, за да започнете.
+ Snap to drawing Прилепване към чертежа
+ Endpoints Крайни точки
+ Midpoints Средни точки
+ Intersections Пресечни точки
+ Paths Контури
+ Sensitivity Чувствителност
+ Unit Единица
+ Decimals Десетични знаци
+ Calibrate from two points Калибрирай по две точки
+ Entire page Цялата страница
+ Viewport rectangle in page points (x, y, width, height) Правоъгълник на изгледа в точки на страницата (x, y, ширина, височина)
+ Viewport Област на изгледа
+ Apply scale Приложи мащаба
+ Saved measurements Запазени измервания
+ Saved measurements. Измерванията са запазени.
+ Endpoint Крайна точка
+ Midpoint Средна точка
+ Intersection Пресечна точка
+ Path Контур
+ This drawing exceeds the snapping limit; only the first paths are used. Този чертеж надвишава ограничението за прилепване; използват се само първите контури.
+ Some page content couldn't be read; snapping uses the rest. Част от съдържанието на страницата не може да бъде прочетена; прилепването използва останалото.
+ Unsupported measurements Неподдържани измервания
+ Label Етикет
+ {n} measurements use an unsupported format and are left out. {n} измервания използват неподдържан формат и са пропуснати.
+ This area has too many overlapping paths; intersection snapping is limited. Тази област има твърде много припокриващи се контури; прилепването към пресечни точки е ограничено.
+ This measurement needs at least {n} points. Това измерване изисква поне {n} точки.
+ {0}: {1} {0}: {1}
+ {dpi} pixels/inch {dpi} пиксела/инч
+ {kind} field Поле {kind}
+ {month} {y} {month} {y}
+ {n} pages selected Избрани страници: {n}
+ {name} — unsaved changes {name} — незаписани промени
+ {s} action Действие {s}
+ 1 page selected Избрана е 1 страница
+ a bookmark cannot be moved inside itself отметка не може да бъде преместена в самата себе си
+ a document must keep at least one page документът трябва да съдържа поне една страница
+ A form field has additional actions Поле на формуляр има допълнителни действия
+ A stream refers to an external file Поток препраща към външен файл
+ A stream uses LZW compression Поток използва LZW компресия
+ Add arrow Добави стрелка
+ Add background Добави фон
+ Add bookmark Добави отметка
+ Add callout Добави изнесен надпис
+ Add checkmark Добави знак за отметка
+ Add cloud Добави облак
+ Add connected lines Добави свързани линии
+ Add cross Добави кръстче
+ Add dot Добави точка
+ Add drawing Добави рисунка
+ Add field Добави поле
+ Add file attachment Добави прикачен файл
+ Add header & footer Добави горен и долен колонтитул
+ Add highlight Добави маркиране
+ Add image Добави изображение
+ Add inserted text Добави вмъкнат текст
+ Add line Добави линия
+ Add link Добави връзка
+ Add oval Добави овал
+ Add polygon Добави многоъгълник
+ Add rectangle Добави правоъгълник
+ Add redaction mark Добави маркировка за заличаване
+ Add signature Добави подпис
+ Add squiggly underline Добави вълнообразно подчертаване
+ Add stamp Добави печат
+ Add sticky note Добави бележка
+ Add strikethrough Добави зачертаване
+ Add text box Добави текстово поле
+ Add underline Добави подчертаване
+ An embedded file has no AFRelationship Вграден файл няма AFRelationship
+ An image asks to be interpolated Изображение изисква интерполация
+ An image has alternates or OPI information Изображение има алтернативи или OPI информация
+ Button Style: Стил на бутона:
+ Change {key} Промени {key}
+ Change comment properties Промени свойствата на коментара
+ Change field properties Промени свойствата на полето
+ Change initial view Промени началния изглед
+ Change initials Промени инициалите
+ Change link properties Промени свойствата на връзката
+ Change signature Промени подписа
+ Check Отметка
+ Check Box Style: Стил на квадратчето за отметка:
+ Choose images Избери изображения
+ Circle Кръг
+ Combo Комбиниран списък
+ Coming in milestone Очаква се в етап
+ Crop page Изрежи страницата
+ Delete bookmark Изтрий отметката
+ Delete comment Изтрий коментара
+ Delete content Изтрий съдържанието
+ Delete document JavaScript Изтрий JavaScript на документа
+ Delete field Изтрий полето
+ Delete image Изтрий изображението
+ Delete link Изтрий връзката
+ Delete page Изтрий страницата
+ Device colour is used, but there is no PDF/A output intent Използва се цвят, зависим от устройството, но няма изходно намерение за PDF/A
+ DeviceCMYK is used: it needs a CMYK output intent (not added automatically) Използва се DeviceCMYK: необходимо е CMYK изходно намерение (не се добавя автоматично)
+ Diagnostic details Диагностични подробности
+ Diamond Ромб
+ Double-click Двукратно щракване
+ Draw your {what} below. Нарисувайте {what} по-долу.
+ Duplicate page Дублирай страницата
+ e.g. en-US напр. en-US
+ Edit comment Редактирай коментара
+ Edit content Редактирай съдържанието
+ Edit document JavaScript Редактирай JavaScript на документа
+ Edit script of {name} Редактирай скрипта на {name}
+ Edit text Редактирай текста
+ Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Вградените файлове трябва също да са PDF/A файлове (за други използвайте PDF/A-3)
+ Erase Изтриване
+ Fill & Sign: change initials Попълни и подпиши: промяна на инициалите
+ Fill & Sign: change signature Попълни и подпиши: промяна на подписа
+ Fill & Sign: remove saved initials Попълни и подпиши: премахване на запазените инициали
+ Fill & Sign: remove saved signature Попълни и подпиши: премахване на запазения подпис
+ Fill in {name} Попълни {name}
+ Flatten Сплескай
+ Flip image Обърни изображението
+ Font: Helvetica Шрифт: Helvetica
+ Go to page {p} Отиди на страница {p}
+ Images and text Изображения и текст
+ Images and text (converted to PDF) Изображения и текст (конвертирани в PDF)
+ Import {name} Импортирай {name}
+ Insert pages from {name} Вмъкни страници от {name}
+ Items marked Ready work today. Елементите, отбелязани като „Готово“, работят още сега.
+ JavaScript JavaScript
+ JavaScript, e.g. getField("total").value JavaScript, напр. getField("total").value
+ List Списък
+ Lock comment Заключи коментара
+ Mark figure as decorative Отбележи фигурата като декоративна
+ Microsoft Word (.docx) Microsoft Word (.docx)
+ Move bookmark Премести отметката
+ Move comment Премести коментара
+ Move image Премести изображението
+ Move page Премести страницата
+ Move pages Премести страниците
+ no such document няма такъв документ
+ nothing in the file matches this document нищо във файла не съответства на този документ
+ open to see what it will include отворете, за да видите какво ще включва
+ Page {p} of {n} Страница {p} от {n}
+ PDF/A… PDF/A…
+ Planned Планирано
+ Planned for milestone Планирано за етап
+ PostScript / EPS PostScript / EPS
+ PostScript XObjects aren't allowed PostScript XObject обекти не са разрешени
+ Presentation (.pptx) Презентация (.pptx)
+ PushButton Бутон
+ Radio Радиобутон
+ Remove redaction marks Премахни маркировките за заличаване
+ Rename bookmark Преименувай отметката
+ Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Изобразяване: hayro (bootstrap) · Интерфейс: egui · Икони: Lucide (ISC) · Шрифтове: Inter, JetBrains Mono, Dancing Script (OFL)
+ Replace page Замени страницата
+ Resize comment Преоразмери коментара
+ Rotate image Завърти изображението
+ Rotate page Завърти страницата
+ Run JavaScript Изпълни JavaScript
+ Save as PDF/A-1b Запиши като PDF/A-1b
+ Save as PDF/A-2b Запиши като PDF/A-2b
+ Save as PDF/A-3b Запиши като PDF/A-3b
+ Save as PDF/UA Запиши като PDF/UA
+ Save as PDF/X Запиши като PDF/X
+ Select a file for the stamp Изберете файл за печата
+ Select Icon Избери икона
+ Set bookmark destination Задай местоназначение на отметката
+ Set status Accepted Задай състояние „Прието“
+ Set status Cancelled Задай състояние „Отменено“
+ Set status Completed Задай състояние „Завършено“
+ Set status None Задай състояние „Няма“
+ Set status Rejected Задай състояние „Отхвърлено“
+ Set status Unmarked Задай състояние „Неотбелязано“
+ Set tab order Задай реда на обхождане
+ Set the image of {name} Задай изображението на {name}
+ Spreadsheet (.xlsx) Електронна таблица (.xlsx)
+ Square Квадрат
+ Star Звезда
+ The document has additional actions (/AA) Документът има допълнителни действия (/AA)
+ The document has document-level JavaScript Документът съдържа JavaScript на ниво документ
+ the document has no form fields документът няма полета на формуляр
+ the document has no page tree документът няма дърво на страниците
+ the document has no tags документът няма тагове
+ The document has no XMP metadata Документът няма XMP метаданни
+ The document is encrypted Документът е шифрован
+ the document is protected by a password документът е защитен с парола
+ The form asks viewers to make appearances (NeedAppearances) Формулярът изисква от програмите за преглед да генерират изгледите на полетата (NeedAppearances)
+ the image is empty изображението е празно
+ The operation failed. Операцията е неуспешна.
+ the password is incorrect паролата е грешна
+ the text recognition models are not installed (run `cargo xtask models`, or set PRINTCRAFT_MODELS) моделите за разпознаване на текст не са инсталирани (изпълнете `cargo xtask models` или задайте PRINTCRAFT_MODELS)
+ The title in the metadata and the document information differ Заглавието в метаданните и в информацията за документа се различават
+ the title must not be empty заглавието не може да е празно
+ there are no pages to print няма страници за печат
+ this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) този документ е подписан: презаписването му би обезсилило подписите (вместо това го запишете инкрементално)
+ this file isn't XFDF, FDF, XML or text form data този файл не е XFDF, FDF, XML или текстови данни на формуляр
+ This page couldn't be displayed.\n{e} Тази страница не може да бъде показана.\n{e}
+ Type your {what}. Въведете {what}.
+ Unknown Неизвестно
+ Unlock comment Отключи коментара
+ Update background Актуализирай фона
+ Update header & footer Актуализирай горния и долния колонтитул
+ Update watermark Актуализирай водния знак
+ Your name Вашето име
+ Change {what} Промени {what}
+ Cut and stack Изрежи и подреди
+ Default workspace mode Режим на работното пространство по подразбиране
+ Default page display Показване на страниците по подразбиране
+ Default zoom Мащаб по подразбиране
+ Documents and view Документи и изглед
+ Fit one full page Побери една цяла страница
+ Fit to width scrolling Побери по ширина с превъртане
+ Open email app Отвори имейл приложението
+ Open link Отвори връзката
+ Open this web page? Да се отвори ли тази уебстраница?
+ Page display: {layout} Показване на страниците: {layout}
+ Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Скриптовете се изпълняват в изолирана среда без достъп до файлове или мрежа. Скрипт, който иска да отвори уебстраница, първо се нуждае от вашето разрешение. При изключен JavaScript стандартните функции на Acrobat за форматиране, валидиране и изчисляване продължават да работят.
+ Sign yourself Подпишете сами
+ Switch to two-page view first to show the cover page Първо превключете към изглед с две страници, за да се покаже корицата
+ 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 файлът не задава оформление или мащаб. Непрекъснатото превъртане никога не спира на границите между страниците.
+ 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} в този документ иска да отвори имейл приложението ви с този адрес:
+ This web address mixes letters from different alphabets, a common way to imitate another site's name. Този уеб адрес смесва букви от различни азбуки — често използван начин за имитиране на името на друг сайт.
+ This web address uses letters from another alphabet, which can look like familiar ones. Този уеб адрес използва букви от друга азбука, които може да изглеждат като познати.
+ CheckBox Квадратче за отметка
+ Create link Създай връзка
+ Duplicate field Дублирай полето
+ Replace pages Замени страници
+ Set alternate text Задай алтернативен текст
+ nothing to redo няма какво да се повтори
+ nothing to undo няма какво да се отмени
+ pages страници
+ there are no redaction marks to apply няма маркировки за заличаване, които да бъдат приложени
+ Create PDF from images Създай PDF от изображения
+ Selected image files: {count} Избрани файлове с изображения: {count}
+ Use image resolution Използвай разделителната способност на изображението
+ Use each image's embedded DPI; use 72 DPI when it is absent. Използва се вградената DPI стойност на всяко изображение; при липса се използва 72 DPI.
+ Use 72 DPI (one point per pixel) Използвай 72 DPI (една точка на пиксел)
+ Use custom DPI Използвай потребителска DPI стойност
+ DPI sets the printed page size without resampling the image. DPI определя размера на отпечатаната страница без преизчисляване на изображението.
+ Create Създай
+ Dynamic XFA form laid out from its template: {pages}, {fields}. Динамичен XFA формуляр, оформен по своя шаблон: {pages}, {fields}.
+ XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA формуляр: полетата и XFA данните му се поддържат синхронизирани, така че другите програми за преглед показват попълненото от вас.
+@plural {n} page|{n} pages {n} страница|{n} страници
+@plural {n} field|{n} fields {n} поле|{n} полета
+ Created a PDF from {n} files Създаден е PDF от {n} файла
+ PDF, images and text PDF, изображения и текст
+ Insert a file before page {label} Вмъкни файл преди страница {label}
+ Insert a file at the end Вмъкни файл в края
+ Save pages Запиши страниците
+ Save these pages as one PDF Запиши тези страници като един PDF файл
+ No changes to save Няма промени за записване
+ Created a PDF from {n} files; left out: {names} Създаден е PDF от {n} файла; пропуснати: {names}
+ Created a PDF from the first {n} files Създаден е PDF от първите {n} файла
+ None of those files can be made into a PDF; use PDFs, images or .txt files Нито един от тези файлове не може да бъде превърнат в PDF; използвайте PDF файлове, изображения или .txt файлове
+ 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… Отиди на страница…
+ Files are combined in this order. Leave Pages empty to take every page. Файловете се обединяват в този ред. Оставете „Страници“ празно, за да се вземат всички страници.
+comment menu Edit Редактирай
+ Undo {action} Отмени {action}
+ Redo {action} Повтори {action}
+signature pad Type Въвеждане
+action wizard Start Старт
+ Margins (in) Полета (in)
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 75cf6d5088..af17a38f39 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -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.
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index afc29fbcdb..c1e3d2fd8c 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -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}
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 64a86273fd..1d27e04f4e 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index ca53e98308..bd36ccd754 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -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 を閉じたときに開いていたファイルを再度開く
diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs
index c6bd7cedff..0e823cf93d 100644
--- a/crates/ui-egui/src/i18n/mod.rs
+++ b/crates/ui-egui/src/i18n/mod.rs
@@ -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 {
/// 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::>(), [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() {
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index d9048a96bb..8e38337de9 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 5b91f55947..167525de69 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -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}
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 1e381f12b2..81f47b4758 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -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 మోడల్లు
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 5e4a6a3db7..5b66f382fb 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -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}请求打开邮件应用并使用以下地址:
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 7696480c36..9a9e1e851a 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -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 時開啟的檔案
diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs
index 53665e29f2..fb3059b1b2 100644
--- a/crates/ui-egui/src/js_ui.rs
+++ b/crates/ui-egui/src/js_ui.rs
@@ -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)));
diff --git a/crates/ui-egui/src/last_session.rs b/crates/ui-egui/src/last_session.rs
new file mode 100644
index 0000000000..bf64ea7ab4
--- /dev/null
+++ b/crates/ui-egui/src/last_session.rs
@@ -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,
+ /// The path of the tab that was active.
+ pub active: Option,
+}
+
+/// 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 =
+ 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 = (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());
+ }
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index db6137b4e0..c70cdc1253 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -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,
+ /// 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,
@@ -374,6 +378,13 @@ pub struct PdfCraftApp {
pub palette_query: String,
pub all_tools_expanded: bool,
pub recent: Vec,
+ /// 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,
/// 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) {
+ 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 = 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 = 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::(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;
}
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 5475947703..20ab700321 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -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::(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),
}
+/// Matching titles plus their ancestors, keeping document paths rather than filtered indexes.
+fn outline_matches(items: &[OutlineItem], query: &str) -> std::collections::HashSet> {
+ 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,
action: &'a mut Option,
@@ -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,
+ matches: &'a std::collections::HashSet>,
+ 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::(id)).unwrap_or(item.open || depth == 0);
+ let mut open = cx.searching || ui.data(|d| d.get_temp::(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));
}
diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs
index e566ba3c39..ad97d1b0b0 100644
--- a/crates/ui-egui/src/signature_drag.rs
+++ b/crates/ui-egui/src/signature_drag.rs
@@ -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,
tag: Option,
@@ -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 {
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),
);
}
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index dc06ff1cad..8351c12659 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -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, 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 };
diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs
index 93074a973a..b4be88417e 100644
--- a/crates/ui-egui/tests/accessibility.rs
+++ b/crates/ui-egui/tests/accessibility.rs
@@ -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
});
diff --git a/crates/ui-egui/tests/actions.rs b/crates/ui-egui/tests/actions.rs
index a1ed7ed2e7..dd4da8d3d3 100644
--- a/crates/ui-egui/tests/actions.rs
+++ b/crates/ui-egui/tests/actions.rs
@@ -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();
diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs
index ea93ad3560..0477c306fb 100644
--- a/crates/ui-egui/tests/bookmarks.rs
+++ b/crates/ui-egui/tests/bookmarks.rs
@@ -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;
diff --git a/crates/ui-egui/tests/buttons.rs b/crates/ui-egui/tests/buttons.rs
index 9c9481d63e..3fae947d20 100644
--- a/crates/ui-egui/tests/buttons.rs
+++ b/crates/ui-egui/tests/buttons.rs
@@ -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
diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs
index 55058b4fc4..27ac83042c 100644
--- a/crates/ui-egui/tests/commands.rs
+++ b/crates/ui-egui/tests/commands.rs
@@ -35,6 +35,7 @@ fn fixture(n: usize) -> Vec {
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
diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs
index 51ae14d725..cb1dc9a3f2 100644
--- a/crates/ui-egui/tests/comments.rs
+++ b/crates/ui-egui/tests/comments.rs
@@ -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));
+}
diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs
index 2264f57f4e..a06d2b076c 100644
--- a/crates/ui-egui/tests/compare.rs
+++ b/crates/ui-egui/tests/compare.rs
@@ -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}"));
diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs
index aec3900154..eefec1ce2a 100644
--- a/crates/ui-egui/tests/content.rs
+++ b/crates/ui-egui/tests/content.rs
@@ -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
diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs
index 634fac3612..2ae7ed5731 100644
--- a/crates/ui-egui/tests/control.rs
+++ b/crates/ui-egui/tests/control.rs
@@ -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();
diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs
index 9f5390bdd8..f8f0e33927 100644
--- a/crates/ui-egui/tests/create.rs
+++ b/crates/ui-egui/tests/create.rs
@@ -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());
diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs
index a866a916c0..1951d24fcf 100644
--- a/crates/ui-egui/tests/document_links.rs
+++ b/crates/ui-egui/tests/document_links.rs
@@ -44,6 +44,7 @@ fn fixture() -> Vec {
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();
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index 1073478233..8225ecf3ca 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -37,6 +37,7 @@ fn fixture(n: usize) -> Vec {
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 {
+ 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 {
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 {
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 = 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 = 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 = 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
diff --git a/crates/ui-egui/tests/export.rs b/crates/ui-egui/tests/export.rs
index f90c009707..4b5a6a77ed 100644
--- a/crates/ui-egui/tests/export.rs
+++ b/crates/ui-egui/tests/export.rs
@@ -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
diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs
index 54fa780daa..2e85ff7659 100644
--- a/crates/ui-egui/tests/fill_sign.rs
+++ b/crates/ui-egui/tests/fill_sign.rs
@@ -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) -> 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());
diff --git a/crates/ui-egui/tests/folders.rs b/crates/ui-egui/tests/folders.rs
index bfc5196d14..583cc35eaa 100644
--- a/crates/ui-egui/tests/folders.rs
+++ b/crates/ui-egui/tests/folders.rs
@@ -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 = (0..MAX_PINNED).map(|i| Path::new(root).join(format!("pinned-{i}")).to_string_lossy().into_owned()).collect();
diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs
index 60c3f3c2f6..460de28adf 100644
--- a/crates/ui-egui/tests/fonts.rs
+++ b/crates/ui-egui/tests/fonts.rs
@@ -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('#')) {
diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs
index 1fde4e1eb0..57a80dff17 100644
--- a/crates/ui-egui/tests/forms.rs
+++ b/crates/ui-egui/tests/forms.rs
@@ -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 {
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");
+ }
+}
diff --git a/crates/ui-egui/tests/identity.rs b/crates/ui-egui/tests/identity.rs
index 87e6a1a797..9a4e3c38f0 100644
--- a/crates/ui-egui/tests/identity.rs
+++ b/crates/ui-egui/tests/identity.rs
@@ -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": " "}"#);
diff --git a/crates/ui-egui/tests/javascript.rs b/crates/ui-egui/tests/javascript.rs
index 19ad05a54c..a5c5a6aff9 100644
--- a/crates/ui-egui/tests/javascript.rs
+++ b/crates/ui-egui/tests/javascript.rs
@@ -33,6 +33,7 @@ fn form() -> Vec {
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
});
diff --git a/crates/ui-egui/tests/layer_actions.rs b/crates/ui-egui/tests/layer_actions.rs
index efa9a1e3a3..95b35ff234 100644
--- a/crates/ui-egui/tests/layer_actions.rs
+++ b/crates/ui-egui/tests/layer_actions.rs
@@ -38,6 +38,7 @@ fn fixture() -> Vec {
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);
diff --git a/crates/ui-egui/tests/links.rs b/crates/ui-egui/tests/links.rs
index 9b5e3215d2..d3ee781966 100644
--- a/crates/ui-egui/tests/links.rs
+++ b/crates/ui-egui/tests/links.rs
@@ -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
});
diff --git a/crates/ui-egui/tests/marks.rs b/crates/ui-egui/tests/marks.rs
index b19607c40d..0a216ba5f6 100644
--- a/crates/ui-egui/tests/marks.rs
+++ b/crates/ui-egui/tests/marks.rs
@@ -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
});
diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs
index 8e9e977444..a62661fd1e 100644
--- a/crates/ui-egui/tests/ocr.rs
+++ b/crates/ui-egui/tests/ocr.rs
@@ -19,6 +19,7 @@ fn scan() -> Vec {
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())]);
diff --git a/crates/ui-egui/tests/pageboxes.rs b/crates/ui-egui/tests/pageboxes.rs
index 6ad018b7eb..865f127707 100644
--- a/crates/ui-egui/tests/pageboxes.rs
+++ b/crates/ui-egui/tests/pageboxes.rs
@@ -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();
diff --git a/crates/ui-egui/tests/pdf_links.rs b/crates/ui-egui/tests/pdf_links.rs
index 8095ec10ca..a149b3d3ba 100644
--- a/crates/ui-egui/tests/pdf_links.rs
+++ b/crates/ui-egui/tests/pdf_links.rs
@@ -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
diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs
index b4a8e45d89..f30be048f9 100644
--- a/crates/ui-egui/tests/perf.rs
+++ b/crates/ui-egui/tests/perf.rs
@@ -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
diff --git a/crates/ui-egui/tests/pickers.rs b/crates/ui-egui/tests/pickers.rs
index df8b1ae5ed..fb0103f4e6 100644
--- a/crates/ui-egui/tests/pickers.rs
+++ b/crates/ui-egui/tests/pickers.rs
@@ -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();
}
diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs
index 3b1a5dc3dc..5f011fa8e6 100644
--- a/crates/ui-egui/tests/prepare.rs
+++ b/crates/ui-egui/tests/prepare.rs
@@ -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
diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs
index 94ab37ff48..53ad1b8653 100644
--- a/crates/ui-egui/tests/print.rs
+++ b/crates/ui-egui/tests/print.rs
@@ -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
});
diff --git a/crates/ui-egui/tests/protect.rs b/crates/ui-egui/tests/protect.rs
index 714d509554..903b15105b 100644
--- a/crates/ui-egui/tests/protect.rs
+++ b/crates/ui-egui/tests/protect.rs
@@ -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
});
diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs
index 6f59bb1688..8cc9eebfc7 100644
--- a/crates/ui-egui/tests/recovery.rs
+++ b/crates/ui-egui/tests/recovery.rs
@@ -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, 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, 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
diff --git a/crates/ui-egui/tests/redact.rs b/crates/ui-egui/tests/redact.rs
index b2037788f3..8351b5974b 100644
--- a/crates/ui-egui/tests/redact.rs
+++ b/crates/ui-egui/tests/redact.rs
@@ -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
diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs
index 22f82c8deb..a777517e63 100644
--- a/crates/ui-egui/tests/signing.rs
+++ b/crates/ui-egui/tests/signing.rs
@@ -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");
diff --git a/crates/ui-egui/tests/stamps.rs b/crates/ui-egui/tests/stamps.rs
index 3665f1875c..ddb24e6a1c 100644
--- a/crates/ui-egui/tests/stamps.rs
+++ b/crates/ui-egui/tests/stamps.rs
@@ -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"));
diff --git a/crates/ui-egui/tests/text_select.rs b/crates/ui-egui/tests/text_select.rs
index c2beef929b..683f391c9e 100644
--- a/crates/ui-egui/tests/text_select.rs
+++ b/crates/ui-egui/tests/text_select.rs
@@ -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();
diff --git a/crates/ui-egui/tests/theme.rs b/crates/ui-egui/tests/theme.rs
index 9a90181f3d..2b16796f4f 100644
--- a/crates/ui-egui/tests/theme.rs
+++ b/crates/ui-egui/tests/theme.rs
@@ -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);
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
index 5299c66488..819adaea44 100644
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -20,6 +20,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();
setup(&mut app);
app
});
@@ -36,6 +37,36 @@ fn home_shows_welcome_and_tools() {
h.get_by_label("Open file");
}
+/// Every piece of text painted on the last frame: its whole text, whether it was cut with "…",
+/// and where it was drawn.
+fn painted_text(h: &Harness<'static, PdfCraftApp>) -> 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()
+}
+
+#[test]
+fn long_recent_names_and_paths_are_cut_before_the_page_count() {
+ let name = format!("{}.pdf", "Quarterly report final version ".repeat(10));
+ let path = format!("/{}/{name}", "A folder with a long name".repeat(8));
+ let h = harness({
+ let (name, path) = (name.clone(), path.clone());
+ move |app| app.recent.push(pdfcraft_ui_egui::RecentFile { name, path, pages: 7, size: 2048 })
+ });
+ let text = painted_text(&h);
+ let detail = text.iter().find(|(s, ..)| s.starts_with("7 pages")).expect("the row shows its page count").2;
+ for whole in [&name, &path] {
+ let (_, cut, rect) = text.iter().find(|(s, ..)| s == whole).expect("the row shows the name and the path");
+ assert!(*cut, "{whole:?} is cut short");
+ assert!(rect.right() < detail.left(), "{whole:?} ends before the page count");
+ }
+}
+
#[test]
fn every_catalog_tool_is_listed_after_view_more() {
let mut h = harness(|_| {});
@@ -72,9 +103,93 @@ fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() {
h.get_by_label(long);
}
+#[test]
+fn overflowing_tabs_can_be_reached_by_scrolling() {
+ let mut h = harness(|app| {
+ for i in 0..20 {
+ app.open_bytes(&format!("document-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap();
+ }
+ });
+ let last = h.get_by_label("document-19.pdf").rect();
+ assert!(last.center().x < 1200.0, "newly opened active tab must be visible: {last:?}");
+ h.state_mut().active = Some(0);
+ h.run_steps(4);
+ let first = h.get_by_label("document-00.pdf").rect();
+ assert!(first.center().x < 1200.0, "changing active tab must reveal it");
+ h.event(egui::Event::PointerMoved(first.center()));
+ for _ in 0..8 {
+ h.event(egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ delta: egui::vec2(-500.0, 0.0),
+ phase: egui::TouchPhase::Move,
+ modifiers: egui::Modifiers::NONE,
+ });
+ h.run_steps(8);
+ }
+ let last = h.get_by_label("document-19.pdf").rect();
+ assert!(last.center().x > 0.0 && last.center().x < 1200.0, "last tab must be reachable: {last:?}");
+ h.get_by_label("document-19.pdf").click();
+ h.run_steps(3);
+ assert_eq!(h.state().active, Some(19));
+ assert!(h.get_by_label_contains("Display theme:").rect().center().x < 1400.0);
+ let close = last.right_center() - egui::vec2(16.0, 0.0);
+ h.event(egui::Event::PointerMoved(close));
+ h.event(egui::Event::PointerButton { pos: close, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
+ h.step();
+ h.event(egui::Event::PointerButton { pos: close, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
+ h.run_steps(4);
+ assert_eq!(h.state().views.len(), 19);
+ assert!(h.state().active.is_some_and(|i| i < 19));
+ if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
+ settle(&mut h);
+ h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap();
+ }
+}
+
+#[test]
+fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
+ for width in [600.0, 900.0] {
+ let mut h = Harness::builder().with_size(egui::vec2(width, 700.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ for i in 0..12 {
+ app.open_bytes(&format!("narrow-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap();
+ }
+ app
+ });
+ h.run_steps(4);
+ let last = h.get_by_label("narrow-11.pdf").rect();
+ assert!(last.center().x > 28.0 && last.right() < width - 100.0, "active tab: {last:?}");
+ h.state_mut().active = Some(0);
+ h.run_steps(4);
+ let first = h.get_by_label("narrow-00.pdf").rect();
+ assert!(first.center().x > 28.0 && first.right() < width - 100.0);
+ h.event(egui::Event::PointerMoved(first.center()));
+ for _ in 0..8 {
+ h.event(egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ delta: egui::vec2(0.0, -500.0),
+ phase: egui::TouchPhase::Move,
+ modifiers: egui::Modifiers::NONE,
+ });
+ h.run_steps(8);
+ }
+ let last = h.get_by_label("narrow-11.pdf").rect();
+ assert!(last.center().x > 28.0 && last.right() < width - 100.0, "scrolled tab: {last:?}");
+ h.get_by_label("narrow-11.pdf").click();
+ h.run_steps(4);
+ assert_eq!(h.state().active, Some(11));
+ assert!(h.get_by_label_contains("Display theme:").rect().right() <= width);
+ if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
+ settle(&mut h);
+ h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap();
+ }
+ }
+}
+
#[test]
fn garbage_input_is_rejected_without_panicking() {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
assert!(app.open_bytes("junk.pdf", None, b"this is not a pdf".to_vec()).is_err());
assert!(app.open_bytes("empty.pdf", None, Vec::new()).is_err());
let mut truncated = FIXTURE.to_vec();
@@ -173,9 +288,11 @@ fn drag_selects_text_and_copy_returns_it() {
#[test]
fn persistence_round_trips_and_tolerates_garbage() {
let mut a = PdfCraftApp::new();
+ a.set_option("language", "en").unwrap();
a.set_option("theme", "dark").unwrap();
let json = a.persist();
let mut b = PdfCraftApp::new();
+ b.set_option("language", "en").unwrap();
b.restore(&json);
assert_eq!(b.theme, pdfcraft_ui_egui::theme::ThemeKind::Dark);
b.restore("{not json");
@@ -246,7 +363,11 @@ impl egui::DroppedFile for Dropped {
#[test]
fn dropping_a_pdf_on_the_window_opens_it() {
- let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new());
+ let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app
+ });
h.run_steps(3);
assert!(h.state().views.is_empty());
let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "dropped.pdf".into(), bytes: FIXTURE.to_vec() });
@@ -258,7 +379,11 @@ fn dropping_a_pdf_on_the_window_opens_it() {
#[test]
fn pdfs_dropped_on_the_combine_tab_join_its_list() {
- let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new());
+ let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app
+ });
h.run_steps(3);
h.state_mut().execute("page.combine");
h.run_steps(2);
@@ -284,7 +409,11 @@ fn a_folder_dropped_on_the_combine_tab_adds_its_pdfs() {
std::fs::create_dir_all(dir.join("inner")).unwrap();
std::fs::write(dir.join("one.pdf"), FIXTURE).unwrap();
std::fs::write(dir.join("inner").join("two.pdf"), FIXTURE).unwrap();
- let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new());
+ let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app
+ });
h.run_steps(3);
h.state_mut().execute("page.combine");
h.run_steps(2);
@@ -321,9 +450,11 @@ fn default_workspace_mode_persists_and_tolerates_invalid_settings() {
use pdfcraft_ui_egui::Mode;
for mode in [Mode::AllTools, Mode::Read, Mode::Edit, Mode::Convert, Mode::Sign] {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.default_mode = mode;
app.set_option("mode", "sign").unwrap();
let mut restored = PdfCraftApp::new();
+ restored.set_option("language", "en").unwrap();
restored.restore(&app.persist());
assert_eq!(restored.default_mode, mode);
restored.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
@@ -331,6 +462,7 @@ fn default_workspace_mode_persists_and_tolerates_invalid_settings() {
}
for json in ["{}", "{not json", r#"{"default_mode": null}"#, r#"{"default_mode": 5}"#, r#"{"default_mode": "unknown"}"#] {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.restore(json);
assert_eq!(app.default_mode, Mode::AllTools);
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
@@ -342,6 +474,7 @@ fn default_workspace_mode_persists_and_tolerates_invalid_settings() {
fn newly_opened_pdfs_use_the_default_workspace() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.restore(r#"{"default_mode": "edit"}"#);
app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::Edit);
@@ -365,6 +498,7 @@ fn explicit_mode_overrides_default_before_and_after_open() {
for before in [false, true] {
for (value, mode) in [("all", Mode::AllTools), ("read", Mode::Read), ("edit", Mode::Edit), ("convert", Mode::Convert), ("sign", Mode::Sign)] {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.default_mode = Mode::Read;
if before {
app.set_option("mode", value).unwrap();
@@ -403,6 +537,7 @@ fn preferences_selects_default_workspace_for_next_open() {
fn explicit_mode_preserves_independent_tool_and_panel_options() {
use pdfcraft_ui_egui::LeftPanel;
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.set_option("tool", "export").unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("mode", "edit").unwrap();
@@ -418,6 +553,7 @@ fn opening_a_pdf_keeps_a_closed_left_panel_and_the_chosen_tool() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
// `--left closed` without `--mode`, default workspace Edit: the panel stays closed.
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.default_mode = Mode::Edit;
app.set_option("left", "closed").unwrap();
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
@@ -425,9 +561,101 @@ fn opening_a_pdf_keeps_a_closed_left_panel_and_the_chosen_tool() {
assert!(!app.left_open);
// Default All Tools: opening another PDF leaves the tool panel the user picked.
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.set_option("tool", "export").unwrap();
app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap();
app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::AllTools);
assert_eq!(app.left, LeftPanel::Tool("export"));
}
+
+fn three_tab_harness() -> Harness<'static, PdfCraftApp> {
+ harness(|app| {
+ for name in ["first.pdf", "middle.pdf", "last.pdf"] {
+ app.open_bytes(name, None, FIXTURE.to_vec()).unwrap();
+ }
+ })
+}
+
+fn close_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
+ // The close button has no separate accessible label. Its centre is defined by
+ // chrome::tab relative to the actual accessible tab rectangle, not a viewport guess.
+ let pos = h.get_by_label(name).rect().right_center() - egui::vec2(16.0, 0.0);
+ h.event(egui::Event::PointerMoved(pos));
+ h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
+ h.run_steps(1);
+ h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
+ h.run_steps(3);
+}
+
+#[test]
+fn closing_tabs_keeps_the_selected_document_or_selects_its_neighbor() {
+ for removed in [0, 1, 2] {
+ let mut h = three_tab_harness();
+ let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
+ h.get_by_label("middle.pdf").click();
+ h.run_steps(3);
+ assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1]));
+ close_named_tab(&mut h, ["first.pdf", "middle.pdf", "last.pdf"][removed]);
+ let expected = if removed == 1 { ids[2] } else { ids[1] };
+ let app = h.state();
+ assert_eq!(app.active_ids().map(|(_, id)| id), Some(expected), "removed tab {removed}");
+ assert_eq!(
+ app.views.iter().map(|view| view.id).collect::>(),
+ ids.iter().copied().enumerate().filter_map(|(i, id)| (i != removed).then_some(id)).collect::>()
+ );
+ assert_eq!(app.session.docs().len(), 2);
+ assert!(app.session.get(ids[removed]).is_none());
+ assert!(app.close_request.is_none(), "clean tabs close without a prompt");
+ for doc in app.session.docs() {
+ assert_eq!(doc.bytes.as_slice(), FIXTURE);
+ assert!(!doc.dirty);
+ }
+ }
+}
+
+#[test]
+fn closing_the_last_active_tab_selects_the_previous_one_then_home() {
+ let mut h = three_tab_harness();
+ let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
+ for (removed, name) in [(2usize, "last.pdf"), (1, "middle.pdf"), (0, "first.pdf")] {
+ close_named_tab(&mut h, name);
+ let app = h.state();
+ assert_eq!(app.active_ids().map(|(_, id)| id), removed.checked_sub(1).map(|index| ids[index]));
+ assert_eq!(app.views.len(), removed);
+ assert_eq!(app.session.docs().len(), removed);
+ assert!(app.session.get(ids[removed]).is_none());
+ }
+ assert_eq!(h.state().active, None);
+ h.get_by_label_contains("Welcome to PdfCraft");
+}
+
+#[test]
+fn cancelling_then_discarding_an_earlier_dirty_tab_preserves_the_selected_document() {
+ let mut h = three_tab_harness();
+ let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
+ h.get_by_label("first.pdf").click();
+ h.run_steps(3);
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Unsaved title".into() }));
+ h.run_steps(3);
+ h.get_by_label("middle.pdf").click();
+ h.run_steps(3);
+ close_named_tab(&mut h, "first.pdf (edited)");
+ assert_eq!(h.state().close_request, Some(pdfcraft_ui_egui::CloseRequest::Tab(ids[0])));
+ h.get_by_label("Cancel").click();
+ h.run_steps(3);
+ assert_eq!(h.state().views.len(), 3);
+ assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1]));
+ assert!(h.state().session.get(ids[0]).unwrap().dirty);
+ close_named_tab(&mut h, "first.pdf (edited)");
+ h.get_by_label("Don't save").click();
+ h.run_steps(3);
+ assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1]));
+ assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), [ids[1], ids[2]]);
+ assert!(h.state().session.get(ids[0]).is_none());
+ assert!(h.state().close_request.is_none());
+ for doc in h.state().session.docs() {
+ assert_eq!(doc.bytes.as_slice(), FIXTURE);
+ assert!(!doc.dirty);
+ }
+}
diff --git a/crates/ui-egui/tests/updates.rs b/crates/ui-egui/tests/updates.rs
index 141c27818f..c06bd8b2c3 100644
--- a/crates/ui-egui/tests/updates.rs
+++ b/crates/ui-egui/tests/updates.rs
@@ -15,6 +15,7 @@ fn source(answer: Result<&str, &str>) -> UpdateSource {
fn harness(answer: Result<&str, &str>) -> Harness<'static, PdfCraftApp> {
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.update_source = Some(source(answer));
app
})
@@ -78,6 +79,7 @@ fn nothing_is_asked_until_the_user_checks() {
let counted = calls.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();
// Settings from a build that had the startup option are ignored.
app.restore(r#"{"check_updates_at_start": true}"#);
let counted = counted.clone();
diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs
index aedeff8281..2d8881eabd 100644
--- a/crates/ui-egui/tests/view.rs
+++ b/crates/ui-egui/tests/view.rs
@@ -35,6 +35,7 @@ fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static,
fn harness_stepping(step_dt: f32, options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_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", "none").unwrap();
@@ -63,6 +64,7 @@ trailer << /Root 1 0 R >>
fn mixed_harness(options: &'static [(&'static str, &'static str)]) -> 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("mixed.pdf", None, MIXED.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
@@ -230,6 +232,7 @@ fn layout_option_rejects_typos() {
#[test]
fn view_options_without_a_document() {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
assert!(app.set_option("default-layout", "single").is_ok(), "the default needs no document");
assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
assert!(app.set_option("cover", "on").is_err(), "cover needs an open document");
@@ -251,6 +254,7 @@ fn default_page_display_is_used_for_new_documents() {
// Persisted preferences survive a restart.
let saved = h.state().persist();
let mut fresh = PdfCraftApp::new();
+ fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
assert_eq!(fresh.view_defaults.layout, PageLayout::Single);
// Garbage keeps the previous default; casing is forgiven; legacy files lack the key.
@@ -259,6 +263,7 @@ fn default_page_display_is_used_for_new_documents() {
fresh.restore(r#"{"default_layout":"TWO-UP"}"#);
assert_eq!(fresh.view_defaults.layout, PageLayout::TwoUp);
let mut legacy = PdfCraftApp::new();
+ legacy.set_option("language", "en").unwrap();
legacy.restore("{}");
assert_eq!(legacy.view_defaults.layout, PageLayout::Continuous);
}
@@ -287,6 +292,7 @@ fn default_zoom_is_used_for_new_documents() {
// Saved settings keep it, and garbage leaves it alone.
let saved = h.state().persist();
let mut fresh = PdfCraftApp::new();
+ fresh.set_option("language", "en").unwrap();
fresh.restore(&saved);
fresh.restore(r#"{"default_zoom":"bogus"}"#);
fresh.open_bytes("again.pdf", None, PAGES.to_vec()).expect("opens");
@@ -463,6 +469,7 @@ trailer << /Root 1 0 R >>
fn form_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.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app
@@ -531,6 +538,7 @@ fn required_radio_buttons_get_a_round_red_border() {
let _gpu = gpu();
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("radios.pdf", None, RADIOS.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
@@ -575,6 +583,7 @@ fn field_highlighting_is_remembered() {
assert!(h.state().views[1].highlight_fields, "the next document opens highlighted");
let saved = h.state().persist();
let mut next = PdfCraftApp::new();
+ next.set_option("language", "en").unwrap();
next.restore(&saved);
next.open_bytes("form.pdf", None, FORM.to_vec()).expect("opens");
assert!(next.views[0].highlight_fields, "and so does the next session");
@@ -666,6 +675,7 @@ trailer << /Root 1 0 R >>
let bytes = pdf.clone();
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_pixels_per_point(ppp).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs
index 39802d6f33..67dcecb2d4 100644
--- a/crates/ui-egui/tests/viewer.rs
+++ b/crates/ui-egui/tests/viewer.rs
@@ -34,6 +34,7 @@ fn fixture(n: usize) -> Vec {
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("a.pdf", None, fixture(5)).unwrap();
app.open_bytes("b.pdf", None, fixture(2)).unwrap();
app
@@ -213,6 +214,7 @@ trailer << /Root 1 0 R >>
.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();
app.open_bytes("damaged.pdf", None, damaged.clone()).unwrap();
app
});
@@ -250,6 +252,7 @@ fn initial_view_is_edited_and_honoured_on_open() {
// Opening the saved file follows it.
let bytes = s.session.save_bytes(id).unwrap();
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.open_bytes("again.pdf", None, bytes.to_vec()).unwrap();
let view = &app.views[0];
assert_eq!((view.current, view.cover, app.right), (1, true, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)));
@@ -559,6 +562,7 @@ fn page_down_steps_every_spread_when_several_fit_on_screen() {
let bytes = sized_fixture(&sizes);
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("book.pdf", None, bytes).unwrap();
app
});
diff --git a/crates/ui-egui/tests/xfa.rs b/crates/ui-egui/tests/xfa.rs
index f03d12c46a..587972d570 100644
--- a/crates/ui-egui/tests/xfa.rs
+++ b/crates/ui-egui/tests/xfa.rs
@@ -36,6 +36,7 @@ const XFA_PACKET: &str = "<< /Length 52 >>\nstream\n) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, bytes).expect("opens");
app
});
diff --git a/docs/localization.md b/docs/localization.md
index d8cff091ad..ceefd815cd 100644
--- a/docs/localization.md
+++ b/docs/localization.md
@@ -1,8 +1,8 @@
# Interface language
-Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Čeština**, **Português (Brasil)**, **Español**, **Français** or **తెలుగు**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
+Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Español**, **Français** or **తెలుగు**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
-**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
+**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, and Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
The control channel exposes the setting through `ui.set`:
@@ -10,11 +10,11 @@ The control channel exposes the setting through `ui.set`:
{"method":"ui.set","params":{"key":"language","value":"ja"}}
```
-The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `cs`, `pt-br`, `es`, `fr`, `te`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `cs`, `pt-br`, `es`, `fr` or `te`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
+The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `es`, `fr`, `te`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `es`, `fr` or `te`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
-Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Telugu translate them (about 1,800 entries each); Czech and Brazilian Portuguese cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
+Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, Spanish, French and Telugu translate them (about 1,800 entries each); Czech and Brazilian Portuguese cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
-Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Czech, Brazilian Portuguese, Spanish and French need only the bundled Latin faces (tested in `tests/fonts.rs`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
+Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, Spanish and French need only the bundled Latin faces (tested in `tests/fonts.rs`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
There is no right-to-left interface language yet. File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly.
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 647ddbf180..19698c1515 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -661,6 +661,18 @@ tools = []
evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty"]
notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it"
+[[feature]]
+id = "core.reopen-last-session"
+title = "Reopen the files from the last session at startup"
+area = "A"
+tier = "P2"
+milestone = "M3"
+status = "shipped"
+commands = []
+tools = []
+evidence = ["crates/ui-egui/tests/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"]
+notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen."
+
[[feature]]
id = "core.usage-rights-preserve"
title = "Preserve usage-rights (UR3) signatures from Reader-extended files"
@@ -746,8 +758,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = []
-evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face"]
-notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)"
+evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana"]
+notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)"
[[feature]]
id = "view.render-image-codecs"
@@ -3494,8 +3506,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.highlight"]
tools = ["comment_add"]
-evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
-notes = "drag over text, or pick the tool with text selected; agents can highlight by text search"
+evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/comments.rs::a_new_highlight_opens_its_note_for_typing", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
+notes = "drag over text, or pick the tool with text selected; a new highlight opens its note for typing in the Comments panel; agents can highlight by text search"
[[feature]]
id = "comment.underline"
@@ -5786,8 +5798,8 @@ milestone = "M9"
status = "shipped"
commands = ["sign.digital"]
tools = ["sign_document"]
-evidence = ["crates/sign/tests/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
-notes = "PBES2 (AES, 3DES) and legacy SHA-1 3DES/RC2 files; RSA, P-256, P-384"
+evidence = ["crates/sign/tests/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/crypto.rs::opens_pkcs12_as_windows_writes_it", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
+notes = "PBES2 (AES, 3DES) and legacy SHA-1 3DES/RC2 files; RSA, P-256, P-384; both the DER OpenSSL writes and the BER Windows writes (indefinite lengths, segmented AuthenticatedSafe)"
[[feature]]
id = "sign.macos-keychain"
diff --git a/vendor/README.md b/vendor/README.md
index 9225714ef5..bfa7029a19 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -7,9 +7,9 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
-| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator` |
+| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it when dropped, after an edit or on closing, so its renders in progress stop instead of running to the end for answers nobody can receive) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders` |
-| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render` |
+| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid` |
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second |
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows); (2) predictor row widths use saturating arithmetic | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream` |
diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs
index a96154119d..e77ff75e58 100644
--- a/vendor/hayro-interpret/src/interpret/mod.rs
+++ b/vendor/hayro-interpret/src/interpret/mod.rs
@@ -25,6 +25,7 @@ use hayro_syntax::page::{Page, Resources};
use kurbo::{Affine, BezPath, Point, Shape};
use smallvec::smallvec;
use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) mod path;
pub(crate) mod state;
@@ -109,6 +110,10 @@ pub struct InterpreterSettings {
/// PdfCraft patch: viewer overrides for optional content groups (object number, generation,
/// visible), applied on top of the document's default configuration (Layers panel toggles).
pub ocg_overrides: Arc>,
+ /// PdfCraft patch: once this is `true`, interpretation stops before the next content
+ /// operator. What was drawn is then incomplete, so only set it when the output will be
+ /// thrown away (a viewer's render whose result nobody can receive any more).
+ pub cancelled: Option>,
}
impl Default for InterpreterSettings {
@@ -129,6 +134,7 @@ impl Default for InterpreterSettings {
render_annotations: true,
hide_comments: false,
ocg_overrides: Arc::new(Vec::new()),
+ cancelled: None,
}
}
}
@@ -325,6 +331,10 @@ pub fn interpret<'a>(
context.save_state();
while let Some(op) = ops.next() {
+ // PdfCraft patch: stop when the caller cancelled (see `InterpreterSettings::cancelled`).
+ if context.settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) {
+ break;
+ }
match op {
TypedInstruction::SaveState(_) => context.save_state(),
TypedInstruction::StrokeColorDeviceRgb(s) => {
diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs
index 3bf5ccec73..ebf3cdc57a 100644
--- a/vendor/hayro/src/renderer.rs
+++ b/vendor/hayro/src/renderer.rs
@@ -23,6 +23,76 @@ use vello_cpu::{
/// PdfCraft patch: largest image drawn, in pixels (2^28 ≈ 268 MP, well above real page images).
const MAX_IMAGE_PIXELS: u64 = 1 << 28;
+
+/// PdfCraft patch: most pieces one dashed stroke may be cut into. Stroke expansion keeps every
+/// dash, so a pattern that is tiny next to its path asks for billions: a fuzzed `/D [[11] 0]` on
+/// a line from x = 92234775807 allocated over 5 GB, and `[0.000001] 0 d` on a 20 pt line over
+/// 10 GB. Measured in release builds: a stroke cut into 250,000 pieces renders in about 16 ms and
+/// 75 MB at its peak (a million took 80 ms and 290 MB). Dotted and dashed lines in real documents
+/// stay far below this.
+const MAX_DASHES_PER_STROKE: f64 = 250_000.0;
+
+/// PdfCraft patch: the dash pattern to stroke `path` with. Empty (a solid line) when the pattern
+/// is invalid, or when it could cut the path into more than [`MAX_DASHES_PER_STROKE`] pieces.
+/// ISO 32000-2 §8.4.3.6 requires nonnegative entries that are not all zero; a pattern whose
+/// period isn't positive (`[-1 -1] 0 d`) made kurbo's search for the starting dash loop for ever.
+fn usable_dash_pattern(path: &BezPath, dashes: &[f32]) -> Vec {
+ if dashes.iter().any(|d| !d.is_finite() || *d < 0.0) {
+ return Vec::new();
+ }
+ let sum: f64 = dashes.iter().map(|d| f64::from(*d)).sum();
+ // kurbo repeats an odd-length pattern once, as SVG does, so a period covers it twice.
+ let (period, per_period) = if dashes.len() % 2 == 1 {
+ (2.0 * sum, dashes.len().saturating_mul(2))
+ } else {
+ (sum, dashes.len())
+ };
+ if !period.is_finite() || period <= 0.0 {
+ return Vec::new();
+ }
+ let (length, restarts) = control_polygon_length(path);
+ // kurbo restarts the pattern at every subpath and after every `ClosePath`, so each restart
+ // can begin up to a whole period of pieces however short its segment is.
+ let pieces = (length / period + restarts as f64) * per_period as f64;
+ if !pieces.is_finite() || pieces > MAX_DASHES_PER_STROKE {
+ return Vec::new();
+ }
+ dashes.iter().map(|d| f64::from(*d)).collect()
+}
+
+/// PdfCraft patch: the length of `path`'s control polygon, which a curve never exceeds, and how
+/// many times kurbo restarts a dash pattern along it (each `MoveTo` and `ClosePath`).
+fn control_polygon_length(path: &BezPath) -> (f64, usize) {
+ let (mut length, mut restarts) = (0.0, 0usize);
+ let (mut start, mut last) = (Point::ORIGIN, Point::ORIGIN);
+ for el in path.elements() {
+ match *el {
+ kurbo::PathEl::MoveTo(p) => {
+ (start, last) = (p, p);
+ restarts = restarts.saturating_add(1);
+ }
+ kurbo::PathEl::LineTo(p) => {
+ length += last.distance(p);
+ last = p;
+ }
+ kurbo::PathEl::QuadTo(c, p) => {
+ length += last.distance(c) + c.distance(p);
+ last = p;
+ }
+ kurbo::PathEl::CurveTo(c1, c2, p) => {
+ length += last.distance(c1) + c1.distance(c2) + c2.distance(p);
+ last = p;
+ }
+ kurbo::PathEl::ClosePath => {
+ length += last.distance(start);
+ last = start;
+ restarts = restarts.saturating_add(1);
+ }
+ }
+ }
+ (length, restarts)
+}
+
pub(crate) struct Renderer {
pub(crate) ctx: RenderContext,
pub(crate) inside_pattern: bool,
@@ -61,7 +131,7 @@ impl Renderer {
}
}
- fn set_stroke_properties(&mut self, stroke_props: &StrokeProps, is_text: bool) {
+ fn set_stroke_properties(&mut self, stroke_props: &StrokeProps, is_text: bool, path: &BezPath) {
let threshold = if is_text { 0.25 } else { 1.0 };
// Best-effort attempt to ensure a line width of at least 1.0, as required by the PDF
@@ -91,8 +161,13 @@ impl Renderer {
miter_limit: stroke_props.miter_limit as f64,
start_cap: stroke_props.line_cap,
end_cap: stroke_props.line_cap,
- dash_pattern: stroke_props.dash_array.iter().map(|n| *n as f64).collect(),
- dash_offset: stroke_props.dash_offset as f64,
+ // PdfCraft patch: see `usable_dash_pattern`.
+ dash_pattern: usable_dash_pattern(path, &stroke_props.dash_array).into(),
+ dash_offset: if stroke_props.dash_offset.is_finite() {
+ stroke_props.dash_offset as f64
+ } else {
+ 0.0
+ },
};
self.ctx.set_stroke(stroke);
@@ -598,7 +673,7 @@ impl Renderer {
is_text: bool,
) {
self.ctx.set_transform(transform);
- self.set_stroke_properties(stroke_props, is_text);
+ self.set_stroke_properties(stroke_props, is_text, path);
let clip_path = self.set_paint(paint, path, true);
if let Some(clip_path) = clip_path.as_ref() {