Merge current main into Pages sidebar select-all fix

This commit is contained in:
Codex committed 2026-10-15 12:56:18 +02:00
commit d8e46c7b74
98 files changed
+4027 -281

No files matched your search

+22 -22
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "48add323da3a6a7643e8019692712e135d57fc996cf8c91397ca155c128a231b"
sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,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 = "dacff0f922e0b5c05614dd424e8d2d42ef50ebf88617f9fb46d6397368831c62"
sha256 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,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 = "050af73f413f418a884779b200399a1f34c60b23f319376720387c4ab8a68bde"
sha256 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,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 = "d10d93d5d8a4263f76ec4ff2b422cec460b462811b05e5835bb8e11a8eca420c"
sha256 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,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 = "8f4b89f85ef458195501f8b94c6ccc49f2613d780e5f96a49c6376f326e21255"
sha256 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,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 = "cccacda996b0864810ca50ab5bbe011f086558d29d3d6b85343c6473774ef3d6"
sha256 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,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 = "1ab4346ba91874eddc9800c15a40d46e2399cbbf90bba4481785e25961b14602"
sha256 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "58e9d3d0bdedc03dfd515c767dc7e83d211b54b52783402357c36d9e4012062b"
sha256 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,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 = "1ab3877b027c600ba3cec01523eac90172098ce800ac32fbee93a98a039e0e71"
sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "93a5c2631cfb458bdd06d12b20783c0e9f70d73ef6cc007651824ff04a7ff772"
sha256 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "dccace4145bf5be6961e03413e1baac3022b23343fb8ec1bb80e4b771b9b15c1"
sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ 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 = "980f345f77d501e1413b734dd343f70a3fc535752a118647e1c3c71719008672"
sha256 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ba02397a61f224605fda9fd17416b5e364724c932392ebdda34153310190c07a"
sha256 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ 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 = "d59371e117366bf36ff8627db5abc355d354dc73b61d09e20c78028a492c2518"
sha256 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "c39f98e74458221d474623a67345e54623307a7cb6396d4927439b5cacfec831"
sha256 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,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 = "ac27ed3c24f09c5e727ecce94de2313621394457f6cc03b7ebd440331ae62b99"
sha256 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b312eadaea1f306399c9285b528bca282a204c559885546cbd1213509a6f08fe"
sha256 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a0f9d57e72d92ff5c8822c38681774b4cd49c6c2a8d174322c3e8e2404bd4e33"
sha256 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4f0505cf19f18e9152d692861dec6ee9bf1127055e799a65a3305e9d0681a9da"
sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f8972322dbe61d739bef4a29e3a4a2a16ac267652375413646273e1c1a917515"
sha256 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5"
[[asset]]
@@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a05bf2b649ce3fcfa2f990871daa2b97a6464db7b313f606868688806d23f8e1"
sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0c6a8713138e05dd8a2996d7e2da097a03e321e5212a4071bbe58c4c846179bf"
sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c"
[[asset]]
path = "vendor/vello_common/assets/probe.png"
Generated
+11
View File
@@ -2088,6 +2088,15 @@ dependencies = [
"serde",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3942,6 +3951,7 @@ dependencies = [
"wayland-client",
"wayland-protocols",
"windows",
"winit",
"winresource",
"winsafe",
]
@@ -4347,6 +4357,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"fontconfig-parser",
"getrandom 0.4.3",
"image",
"js-sys",
+11
View File
@@ -157,6 +157,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
@@ -164,6 +167,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-14 (M4/#2052 candidate):** Ctrl/Cmd+A selects all thumbnails while the Pages sidebar owns the keyboard, preserves the current page, and keeps viewer/text-field shortcuts and document selection separate. Escape and existing permission checks are retained. Focus regression and scoped UI validation pass; estimates unchanged.
- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged.
- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged.
- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged.
- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged.
- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged.
- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged.
@@ -484,6 +493,8 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | M4/#2052 candidate: Pages sidebar select-all, focus ownership, Escape and document isolation validated; estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. |
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
+96
View File
@@ -239,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
}
}
/// Two pages whose catalog points at a page tree object the file does not have (#1656). The
/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages.
/// Object 3 is written before object 2, so file order and object numbers disagree.
fn fixture_without_page_tree() -> Vec<u8> {
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
let objs: Vec<(usize, Vec<u8>)> = vec![
(1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()),
(3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()),
(2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()),
(4, stream("0 0 m 10 10 l S")),
(5, stream("0 0 m 20 20 l S")),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0; objs.len()];
for (num, o) in &objs {
offsets[num - 1] = out.len();
out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() {
let input = tmp("no-page-tree", "in.pdf");
std::fs::write(&input, fixture_without_page_tree()).unwrap();
let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap();
assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::<Vec<_>>(), [(200.0, 300.0), (300.0, 200.0)]);
for (name, args, expected) in [
// Width and height are as displayed: the turned page reads 200 x 300.
("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]),
("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]),
] {
let output = tmp("no-page-tree", name);
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
all.extend(args);
let out = Command::new(BIN).args(&all).output().unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect();
assert_eq!(pages, expected, "{name}");
}
}
/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the
/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text
/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file.
fn fixture_whose_scan_disagrees() -> Vec<u8> {
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(),
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(),
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(),
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(),
stream("0 0 m 10 10 l S"),
stream("0 0 m 20 20 l S"),
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() {
let input = tmp("scan-disagrees", "in.pdf");
std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap();
let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap();
assert_eq!(shown.pages.len(), 3);
assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page");
let output = tmp("scan-disagrees", "out.pdf");
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n");
assert!(!output.exists(), "no page may be deleted");
}
/// A page past the end names its own option and the page count left by the earlier options (#1550).
#[test]
fn a_page_past_the_end_after_earlier_edits_names_that_option() {
+4
View File
@@ -48,6 +48,10 @@ raw-window-handle = "0.6"
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
pollster = "1.0"
[target.'cfg(target_os = "linux")'.dev-dependencies]
# Exercise the vendored Wayland scroll policy with the actual winit event type.
winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
+1 -1
View File
@@ -3,7 +3,7 @@
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page
//! that would not render, a render worker that could not start, the panic hook's report) vanished.
//! A launch from a desktop menu has no terminal, so the file is what a bug report can attach:
//! `<settings dir>/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each
//! `<data dir>/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each
//! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run
//! that crashed survives the next launch.
//!
+22 -9
View File
@@ -37,6 +37,7 @@ mod logging;
#[cfg(target_os = "windows")]
mod motion;
mod reopen;
mod settings;
mod single_instance;
mod text_scale;
mod updates;
@@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256
/// The app was called PrintCraft before; settings saved then are under this key.
const LEGACY_STORAGE_KEY: &str = "printcraft";
/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode
/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the
/// app id; keep it under "PdfCraft".
/// The preferences folder (`app.ron`, docs/development.md). In portable mode
/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base;
/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder.
fn settings_dir() -> Option<std::path::PathBuf> {
if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
return Some(dir.to_path_buf());
let legacy = eframe::storage_dir("PdfCraft");
match settings::directory(
cfg!(all(unix, not(target_os = "macos"))),
pdfcraft_ui_egui::portable::data_dir(),
legacy.clone(),
std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from),
std::env::home_dir(),
) {
Ok(dir) => dir,
Err(e) => {
log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}");
legacy
}
}
eframe::storage_dir("PdfCraft")
}
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
@@ -75,7 +86,7 @@ fn migrate_legacy_folders() {
if pdfcraft_ui_egui::portable::data_dir().is_some() {
return;
}
let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))];
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
if cfg!(windows) {
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
@@ -203,10 +214,12 @@ fn main() -> eframe::Result {
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// The log file stays in the data folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
if let (Some(logger), Some(dir)) =
(logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft")))
{
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
+194
View File
@@ -0,0 +1,194 @@
//! Platform preference location, separate from logs and crash recovery.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static STAGING_ID: AtomicU64 = AtomicU64::new(0);
pub fn directory(
xdg: bool,
portable: Option<&Path>,
legacy: Option<PathBuf>,
config_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> std::io::Result<Option<PathBuf>> {
if let Some(portable) = portable {
return Ok(Some(portable.to_path_buf()));
}
if !xdg {
return Ok(legacy);
}
// XDG requires absolute environment paths; empty and relative overrides are ignored.
let preferred = config_home.filter(|p| p.is_absolute()).or_else(|| home.filter(|p| p.is_absolute()).map(|p| p.join(".config")));
let Some(preferred) = preferred.map(|p| p.join("pdfcraft")) else { return Ok(legacy) };
let target = preferred.join("app.ron");
if std::fs::symlink_metadata(&target).is_ok() {
return Ok(Some(preferred));
}
create_private_directory(&preferred)?;
if let Some(source) = legacy.map(|p| p.join("app.ron")) {
match std::fs::File::open(&source) {
Ok(mut source) => migrate(&mut source, &preferred, &target)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(Some(preferred))
}
/// Create private configuration directories without changing existing directory permissions.
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(directory)
}
/// Publish a complete copy without replacing a concurrent launch's preferences. Keep the
/// original file and all other data (especially recovery and digital IDs) where they were.
fn migrate(source: &mut std::fs::File, directory: &Path, target: &Path) -> std::io::Result<()> {
create_private_directory(directory)?;
for _ in 0..32 {
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
let staging = directory.join(format!(".app.ron-migrate-{}-{id}", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = match options.open(&staging) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
};
let result = (|| {
std::io::copy(source, &mut file)?;
file.sync_all()?;
// Both files are in the same directory/filesystem. Unlike rename, hard_link
// atomically refuses an existing destination on Unix as well as Windows.
match std::fs::hard_link(&staging, target) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
result => result,
}
})();
drop(file);
let _ = std::fs::remove_file(&staging);
return result;
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free settings migration staging name"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xdg_preferences_use_config_instead_of_data() {
let fixture = Fixture::new();
let home = fixture.0.join("home");
let data = home.join(".local/share/pdfcraft");
assert_eq!(directory(true, None, Some(data), None, Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("pdfcraft-settings-{}-{id}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn xdg_override_and_invalid_overrides() {
let f = Fixture::new();
let home = f.0.join("home");
let config = f.0.join("custom-config");
assert_eq!(directory(true, None, None, Some(config.clone()), Some(home.clone())).unwrap(), Some(config.join("pdfcraft")));
for invalid in [PathBuf::new(), PathBuf::from("relative")] {
assert_eq!(directory(true, None, None, Some(invalid), Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
}
assert_eq!(directory(true, None, Some(f.0.clone()), None, None).unwrap(), Some(f.0.clone()));
}
#[test]
fn migration_preserves_preferences_and_other_data() {
let f = Fixture::new();
let data = f.0.join("data/pdfcraft");
let config = f.0.join("config");
std::fs::create_dir_all(data.join("recovery")).unwrap();
std::fs::write(data.join("app.ron"), b"(theme:Dark,language:German)").unwrap();
std::fs::write(data.join("recovery/unsaved.pdf"), b"UNSAVED").unwrap();
std::fs::write(data.join("digital-id.p12"), b"KEY").unwrap();
let selected = directory(true, None, Some(data.clone()), Some(config.clone()), None).unwrap().unwrap();
assert_eq!(selected, config.join("pdfcraft"));
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), std::fs::read(data.join("app.ron")).unwrap());
assert_eq!(std::fs::read(data.join("recovery/unsaved.pdf")).unwrap(), b"UNSAVED");
assert_eq!(std::fs::read(data.join("digital-id.p12")).unwrap(), b"KEY");
// The migrated file is a copy, not a link to the original settings.
std::fs::write(selected.join("app.ron"), b"NEW").unwrap();
assert_eq!(std::fs::read(data.join("app.ron")).unwrap(), b"(theme:Dark,language:German)");
assert_eq!(directory(true, None, Some(data), Some(config), None).unwrap(), Some(selected.clone()));
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), b"NEW");
assert_eq!(std::fs::read_dir(selected).unwrap().count(), 1);
}
#[test]
fn existing_or_concurrently_created_preferences_are_never_replaced() {
let f = Fixture::new();
let source = f.0.join("old.ron");
let config = f.0.join("config/pdfcraft");
std::fs::write(&source, b"OLD").unwrap();
std::fs::create_dir_all(&config).unwrap();
let target = config.join("app.ron");
std::fs::write(&target, b"CURRENT").unwrap();
migrate(&mut std::fs::File::open(source).unwrap(), &config, &target).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"CURRENT");
assert_eq!(std::fs::read_dir(config).unwrap().count(), 1);
}
#[test]
fn failed_migration_keeps_original_available() {
let f = Fixture::new();
std::fs::write(f.0.join("app.ron"), b"OLD").unwrap();
let blocked = f.0.join("blocked");
std::fs::write(&blocked, b"NOT A DIRECTORY").unwrap();
assert!(directory(true, None, Some(f.0.clone()), Some(blocked), None).is_err());
assert_eq!(std::fs::read(f.0.join("app.ron")).unwrap(), b"OLD");
}
#[test]
fn portable_and_non_xdg_platforms_keep_their_locations() {
let f = Fixture::new();
let portable = f.0.join("PdfCraftData");
for xdg in [true, false] {
assert_eq!(directory(xdg, Some(&portable), Some(f.0.clone()), Some(f.0.clone()), None).unwrap(), Some(portable.clone()));
}
assert_eq!(directory(false, None, Some(f.0.clone()), Some(portable), None).unwrap(), Some(f.0.clone()));
assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 0, "no migration outside XDG mode");
}
#[cfg(unix)]
#[test]
fn new_config_directory_is_private_and_existing_permissions_are_preserved() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new();
let selected = directory(true, None, None, Some(f.0.join("config")), None).unwrap().unwrap();
assert_eq!(std::fs::metadata(&selected).unwrap().permissions().mode() & 0o777, 0o700);
std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o750)).unwrap();
create_private_directory(&selected).unwrap();
assert_eq!(std::fs::metadata(selected).unwrap().permissions().mode() & 0o777, 0o750);
}
}
@@ -0,0 +1,76 @@
#![cfg(target_os = "linux")]
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
use wayland_client::protocol::wl_pointer::AxisSource;
// Compile the production policy against winit's real event type, without a compositor.
mod event {
pub use winit::event::TouchPhase;
}
#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
mod scroll;
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
match phase {
event::TouchPhase::Started => TouchPhase::Start,
event::TouchPhase::Moved => TouchPhase::Move,
event::TouchPhase::Ended => TouchPhase::End,
event::TouchPhase::Cancelled => TouchPhase::Cancel,
}
}
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
let ctx = egui::Context::default();
let mut previous = event::TouchPhase::Ended;
let mut seen = Vec::new();
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (frame, modifiers) in [ctrl, ctrl, Modifiers::NONE].into_iter().enumerate() {
let phase = scroll::phase(previous, Some(source), discrete, false);
previous = phase;
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
let input = RawInput {
time: Some(frame as f64 / 60.0),
events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
..Default::default()
};
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
}
seen
}
#[test]
fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
let seen = wheel_trace(source, false);
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
assert_eq!(seen[2].0, 1.0, "releasing Ctrl must stop zooming: {source:?}");
assert!(seen[2].1.y > 0.0, "the next wheel event must scroll: {source:?}");
}
}
#[test]
fn discrete_wheels_still_follow_current_modifiers() {
let seen = wheel_trace(AxisSource::Wheel, true);
assert!(seen[1].0 > 1.0);
assert_eq!(seen[2].0, 1.0);
assert!(seen[2].1.y > 0.0);
}
#[test]
fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
// A finger scroll's momentum may arrive after Ctrl is released.
let seen = wheel_trace(AxisSource::Finger, false);
assert!(seen[1].0 > 1.0);
assert!(seen[2].0 > 1.0);
assert_eq!(seen[2].1, egui::Vec2::ZERO);
assert_eq!(scroll::phase(event::TouchPhase::Moved, Some(AxisSource::Finger), false, true), event::TouchPhase::Ended);
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
}
#[test]
fn unknown_sources_keep_the_existing_phase_policy() {
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
assert_eq!(scroll::phase(event::TouchPhase::Moved, None, false, true), event::TouchPhase::Ended);
}
+5
View File
@@ -25,6 +25,11 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.edurbs]
real_name = "Eduardo Soares"
display_name = "Eduardo Soares"
sync_github_name = false
[people.jcl-bit]
real_name = "James Locke"
display_name = "James Locke"
+138
View File
@@ -0,0 +1,138 @@
//! Conservative tagging for AcroForm widgets.
//!
//! It creates `/Form` structure elements and `/OBJR` children so assistive technology can find
//! widgets. An existing direct `/Document` root and direct `/ParentTree /Nums` are extended;
//! unusual tree layouts are refused before any edit is made.
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
fn reference(object: Option<&Object>, what: &str) -> Result<ObjRef, String> {
object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference"))
}
fn nums(doc: &Document, parent_tree: ObjRef) -> Result<Vec<Object>, String> {
let tree = doc.get(parent_tree);
let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?;
tree.get(b"Nums")
.map(|value| doc.resolve(value))
.as_deref()
.and_then(Object::as_array)
.cloned()
.ok_or_else(|| "the structure parent tree must use a direct Nums array".to_string())
}
/// Add `/Form` tags for every page widget that does not already have a structure parent.
///
/// A new structure tree is written for an untagged form. A conventional existing tree is
/// extended by adding the form tags to its direct `/Document` element and fresh entries to its
/// direct `/ParentTree /Nums` array. Text, figures, links, and non-widget annotations remain
/// outside this operation and need separate review.
pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let root = doc.root().ok_or("the PDF has no catalog")?;
let catalog_object = doc.get(root);
let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?;
if !catalog.contains(b"AcroForm") {
return Err("the PDF has no AcroForm fields".into());
}
let existing_root = catalog.get(b"StructTreeRoot").cloned();
let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let mut widgets = Vec::new();
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
let mut seen = std::collections::HashSet::new();
for page in &pages {
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
for annot in annots {
let Some(reference) = annot.as_ref() else { continue };
let resolved = doc.get(reference);
let Some(dict) = resolved.as_dict() else { continue };
if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) {
widgets.push((page.obj, reference));
}
}
}
if widgets.is_empty() {
return Err("the AcroForm has no untagged page widgets".into());
}
let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root {
let struct_root = reference(Some(&existing_root), "structure tree root")?;
let structure_object = doc.get(struct_root);
let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?;
let document_ref = reference(structure.get(b"K"), "structure tree root K")?;
let document = doc.get(document_ref);
if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) {
return Err("the structure tree root must point directly to a Document element".into());
}
let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?;
let parent_nums = nums(doc, parent_tree)?;
if parent_nums.len() % 2 != 0 {
return Err("the structure parent tree has an odd number of entries".into());
}
let mut greatest = -1_i64;
for pair in parent_nums.as_chunks::<2>().0 {
let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) };
if key < 0 {
return Err("the structure parent tree has a negative key".into());
}
greatest = greatest.max(key);
}
let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
(struct_root, document_ref, parent_tree, parent_nums, first_key, false)
} else {
let mut root_dict = Dict::new();
root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec()));
let struct_root = doc.add(Object::Dict(root_dict));
let mut document = Dict::new();
document.set(b"S".to_vec(), Object::Name(b"Document".to_vec()));
document.set(b"P".to_vec(), Object::Ref(struct_root));
let document_ref = doc.add(Object::Dict(document));
let parent_tree = doc.add(Object::Dict(Dict::new()));
(struct_root, document_ref, parent_tree, Vec::new(), 0, true)
};
let mut kids = Vec::with_capacity(widgets.len());
for (offset, (page, widget)) in widgets.iter().copied().enumerate() {
let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?;
let mut objr = Dict::new();
objr.set(b"Type".to_vec(), Object::Name(b"OBJR".to_vec()));
objr.set(b"Obj".to_vec(), Object::Ref(widget));
objr.set(b"Pg".to_vec(), Object::Ref(page));
let mut form = Dict::new();
form.set(b"S".to_vec(), Object::Name(b"Form".to_vec()));
form.set(b"P".to_vec(), Object::Ref(document_ref));
form.set(b"Pg".to_vec(), Object::Ref(page));
form.set(b"K".to_vec(), Object::Dict(objr));
let form_ref = doc.add(Object::Dict(form));
doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?;
parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]);
kids.push(Object::Ref(form_ref));
}
doc.update_dict(document_ref, |d| {
let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new()));
let mut all = match old {
Object::Array(items) => items,
item => vec![item],
};
all.extend(kids);
d.set(b"K".to_vec(), Object::Array(all));
})
.map_err(|e| e.to_string())?;
doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?;
if new_tree {
doc.update_dict(struct_root, |d| {
d.set(b"K".to_vec(), Object::Ref(document_ref));
d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
})
.map_err(|e| e.to_string())?;
}
let mut mark_info = mark_info;
mark_info.set(b"Marked".to_vec(), Object::Bool(true));
doc.update_dict(root, |d| {
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
if new_tree {
d.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root));
}
})
.map_err(|e| e.to_string())?;
Ok(widgets.len())
}
+1 -1
View File
@@ -178,7 +178,7 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict {
/// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]).
/// Text-positioning operators alone don't break words: producers that place every glyph with its
/// own `Td` (Chromium) would otherwise read as "C h a p t e r".
fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
+3 -1
View File
@@ -14,6 +14,7 @@ use pdfcraft_cos::Document;
mod alt;
mod autotag;
mod autotag_fields;
mod content;
mod headings;
mod report;
@@ -22,9 +23,10 @@ mod tags;
pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt};
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
pub use headings::{Heading, headings};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Category {
+60
View File
@@ -21,6 +21,14 @@ pub struct TagInfo {
pub page: Option<usize>,
}
/// A marked-content reference directly owned by a structure tag.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagContentItem {
pub page: Option<usize>,
pub mcid: i64,
pub text: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagProperty {
Type,
@@ -140,6 +148,58 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
.collect()
}
/// List marked-content references directly owned by an indirect tag.
pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>, TagError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) };
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
let mut marks = Vec::new();
if let Some(kids) = element.dict.get(b"K") {
direct_marks(doc, kids, element.page, &mut marks);
}
// Each page's content is parsed once, however many of its MCIDs the tag owns.
let mut texts: std::collections::HashMap<usize, std::collections::HashMap<i64, String>> = std::collections::HashMap::new();
Ok(marks
.into_iter()
.map(|(page, mcid)| {
let page = page.and_then(|p| page_index.get(&p).copied());
let text = page
.and_then(|i| {
let page_texts = match texts.entry(i) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default())
}
};
page_texts.get(&mcid).cloned()
})
.unwrap_or_default();
TagContentItem { page, mcid, text }
})
.collect())
}
fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut Vec<(Option<ObjRef>, i64)>) {
match item {
Object::Int(mcid) => out.push((page, *mcid)),
Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)),
Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => {
if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) {
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
}
Object::Ref(reference) => {
if let Some(dict) = doc.get(*reference).as_dict()
&& dict.name(b"Type") == Some(b"MCR")
&& let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int)
{
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
}
_ => {}
}
}
/// Change one property of a reachable tag; `None` removes a text property.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
if !alt::elements(doc).iter().any(|e| e.object == object) {
+91
View File
@@ -96,6 +96,78 @@ fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
}
#[test]
fn autotag_form_fields_writes_widget_structure_and_parent_tree() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /FT /Tx /T (name) /Kids [6 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 60 120 80] >>".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedFormFields].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed);
let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
assert!(doc.get(tree).as_dict().unwrap().contains(b"ParentTree"));
let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[1].as_ref().unwrap();
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(0));
let written = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(written)).unwrap();
assert_eq!(status(&check(&reopened, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }), Rule::TaggedFormFields), Status::Passed);
}
#[test]
fn autotag_form_fields_tags_a_widget_listed_on_two_pages_once() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
let parent_tree = doc.get(tree).as_dict().unwrap().reference(b"ParentTree").unwrap();
let nums = doc.get(parent_tree).as_dict().unwrap().get(b"Nums").and_then(|n| n.as_array().cloned()).unwrap();
assert_eq!(nums.len(), 2, "one key and one /Form element for the shared widget");
}
#[test]
fn autotag_form_fields_extends_a_direct_existing_structure_tree() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
"<< /Type /StructTreeRoot /K 7 0 R /ParentTree 8 0 R >>".into(),
"<< /S /Document /P 6 0 R /K 9 0 R >>".into(),
"<< /Nums [0 [9 0 R]] >>".into(),
"<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[0].as_ref().unwrap();
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(1));
let report = check(&doc, &Options { rules: [Rule::TaggedFormFields].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed);
let document_object = doc.get(pdfcraft_cos::ObjRef::new(7, 0));
let document = document_object.as_dict().unwrap();
assert_eq!(document.get(b"K").and_then(Object::as_array).unwrap().len(), 2, "the existing paragraph and new form remain siblings");
}
#[test]
fn an_untagged_document_fails_the_document_rules() {
let doc = pdf(
@@ -526,6 +598,16 @@ fn figures_are_listed_with_their_alt_text_and_place() {
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
}
#[test]
fn tag_content_lists_direct_marked_content() {
let doc = figures_doc();
let tags = tags(&doc);
let first = tag_content(&doc, tags[1].object).unwrap();
let second = tag_content(&doc, tags[2].object).unwrap();
assert_eq!((first[0].page, first[0].mcid, first[0].text.as_str()), (Some(0), 0, ""));
assert_eq!((second[0].page, second[0].mcid, second[0].text.as_str()), (Some(0), 1, ""));
}
#[test]
fn sibling_tags_move_in_reading_order_without_changing_content() {
let mut doc = figures_doc();
@@ -729,6 +811,15 @@ fn headings_are_found_with_their_levels_titles_and_pages() {
);
}
#[test]
fn tag_content_reads_the_text_of_each_marked_content_reference() {
let doc = pdf(&headings_doc(), "");
let items = |n| tag_content(&doc, ref_(n).unwrap()).unwrap().into_iter().map(|i| (i.page, i.mcid, i.text)).collect::<Vec<_>>();
assert_eq!(items(12), [(Some(0), 0, "Chapter One".to_string())]);
assert_eq!(items(13), [(Some(0), 2, "Sec A".to_string())]);
assert_eq!(items(15), [(Some(1), 1, "AB".to_string())]);
}
/// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw.
fn helvetica_width(c: char) -> f64 {
match c {
+4
View File
@@ -157,6 +157,10 @@ For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based i
`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the
working document unchanged.
`page_render.hide_added_text` does the same for an added text item's 1-based index from
`content_list` or `object_list`. It removes only that item in a read-only preview and is
mutually exclusive with `hide_text_block`.
## Existing text and document fonts
+31
View File
@@ -217,6 +217,19 @@ impl Automation {
}))
}
pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?;
let report = self
.doc(a)?
.accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None })
.ok_or_else(|| failed("the document can't be read"))?;
let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?;
Ok(
json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }),
)
}
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
@@ -273,6 +286,14 @@ impl Automation {
Ok(report_json(&r))
}
/// Report only untagged page content, annotations, and AcroForm widgets.
pub(crate) fn a11y_find_untagged(&self, a: &Args) -> Result<Value> {
let pages = a.opt_ints("pages")?.map(|_| self.pages(a, "pages")).transpose()?;
let rules = [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect();
let report = self.doc(a)?.accessibility_check(&Options { rules, pages }).ok_or_else(|| failed("the document can't be read for checking"))?;
Ok(report_json(&report))
}
pub(crate) fn a11y_report(&self, a: &Args) -> Result<Value> {
let o = self.a11y_options(a)?;
let doc = self.doc(a)?;
@@ -490,6 +511,16 @@ impl Automation {
Ok(json!({ "count": count, "tags": tags, "next": next }))
}
pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
let generation =
u16::try_from(a.int("generation")?).map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation".into()))?;
let content = self.doc(a)?.tag_content(pdfcraft_cos::ObjRef { num: object, generation }).map_err(failed)?;
Ok(
json!({ "count": content.len(), "content": content.into_iter().map(|item| json!({ "page": item.page.map(|p| p + 1), "mcid": item.mcid, "text": item.text })).collect::<Vec<_>>() }),
)
}
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
+15 -1
View File
@@ -763,8 +763,10 @@ impl Automation {
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
"accessibility_check" => self.a11y_check(&a)?,
"accessibility_find_untagged" => self.a11y_find_untagged(&a)?,
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
"accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?,
"ocr_recognize" => self.ocr_recognize(&a)?,
"js_run" => self.js_run(&a)?,
"js_document_scripts" => self.js_document_scripts(&a)?,
@@ -795,6 +797,7 @@ impl Automation {
"accessibility_figures" => self.accessibility_figures(&a)?,
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
"accessibility_tags" => self.accessibility_tags(&a)?,
"accessibility_tag_content" => self.accessibility_tag_content(&a)?,
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
"accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?,
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
@@ -1795,12 +1798,23 @@ impl Automation {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let scale = (dpi / 72.0) as f32;
let out = if let Some(block) = a.opt_int("hide_text_block")? {
let block = a.opt_int("hide_text_block")?;
let added = a.opt_int("hide_added_text")?;
if block.is_some() && added.is_some() {
return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into()));
}
let out = if let Some(block) = block {
let index = usize::try_from(block)
.ok()
.and_then(|n| n.checked_sub(1))
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
} else if let Some(added) = added {
let index = usize::try_from(added)
.ok()
.and_then(|n| n.checked_sub(1))
.ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?;
self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
} else {
self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false })
};
+1 -1
View File
@@ -136,7 +136,7 @@ impl Automation {
} else {
None
};
let settings = print::Settings { pages, paper, orientation, layout, content, raster };
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() };
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len();
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
+13 -1
View File
@@ -161,7 +161,8 @@ pub fn tools() -> Vec<ToolDef> {
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." },
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
@@ -928,6 +929,9 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_autotag_form_fields", "Tag form fields", "Write `/Form` tags, widget structure-parent keys, and a parent tree for all page widgets in an untagged AcroForm. Existing tag trees are refused for review. Text, figures, links, and other content are not tagged.")
.cmd("a11y.autotag_fields")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.ro()
.cmd("a11y.check")
@@ -941,6 +945,10 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("accessibility_find_untagged", "Find untagged content", "Find untagged painted page content, annotations, and form widgets. This focused check does not modify the PDF or claim that its other accessibility rules pass.")
.ro()
.cmd("a11y.find_untagged")
.with(schema(json!({ "doc": doc(), "pages": pages("to search (default: all)") }), &["doc"])),
t("accessibility_report", "Accessibility report", "Run the full check and write the accessibility report (HTML) to path; returns the results too. Takes the same options as accessibility_check.")
.destructive()
.cmd("a11y.report")
@@ -1006,6 +1014,10 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("accessibility_tag_content", "List tag content", "List the marked-content references directly owned by one reachable indirect structure tag, including page, MCID and extracted text.")
.ro()
.cmd("a11y.tags")
.with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])),
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
+59 -1
View File
@@ -291,7 +291,22 @@ fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() {
let pages = pdfcraft_model::pages(&reopened);
assert_eq!(pages.len(), 2);
assert!(pages.iter().all(|page| page.dict.contains(b"StructParents")));
assert!(reopened.get(reopened.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
}
#[test]
fn autotag_form_fields_writes_reviewable_widget_tags() {
let dir = workdir("autotag-form-fields");
std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap();
let tagged = ok(&mut a, "accessibility_autotag_form_fields", json!({ "doc": doc }));
assert_eq!(tagged["status"], "passed");
assert_eq!(tagged["remaining_findings"], 0);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap();
let check = ok(&mut a, "accessibility_check", json!({ "doc": reopened, "rules": ["tagged-form-fields"] }));
let result = check["results"].as_array().unwrap().iter().find(|result| result["rule"] == "tagged-form-fields").unwrap();
assert_eq!(result["status"], "passed");
}
/// A one-page PDF whose form holds one text field named `name`.
@@ -606,6 +621,29 @@ fn text_editor_background_is_read_only_and_removes_source_glyphs() {
}
}
#[test]
fn added_text_editor_background_hides_only_the_item_without_editing() {
let dir = workdir("added-text-preview");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let render = json!({"doc":doc,"page":1,"dpi":72});
let before = a.call("page_render", &render).unwrap();
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Retype this label","at":[20,20],"width":160}));
let drawn = a.call("page_render", &render).unwrap();
assert_ne!(drawn, before);
let state = ok(&mut a, "doc_info", json!({"doc":doc}));
let items = ok(&mut a, "content_list", json!({"doc":doc}));
let preview = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_added_text":1})).unwrap();
assert_eq!(preview, before, "native text and the page background stay intact");
assert_eq!(a.call("page_render", &render).unwrap(), drawn);
assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc})), state);
assert_eq!(ok(&mut a, "content_list", json!({"doc":doc})), items);
for index in [-1, 0, 2, i64::MAX] {
assert!(a.call("page_render", &json!({"doc":doc,"page":1,"hide_added_text":index})).is_err());
}
assert!(a.call("page_render", &json!({"doc":doc,"page":1,"hide_added_text":1,"hide_text_block":1})).is_err());
}
#[test]
fn edit_undo_redo_and_save_round_trip() {
let dir = workdir("edit");
@@ -6372,6 +6410,26 @@ fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() {
assert_eq!(opened["destination_applied"], true);
}
#[test]
fn finding_untagged_content_limits_results_to_content_annotations_and_widgets() {
let dir = workdir("find-untagged");
std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap();
let found = ok(&mut a, "accessibility_find_untagged", json!({ "doc": doc }));
let active: Vec<&str> = found["results"]
.as_array()
.unwrap()
.iter()
.filter(|result| result["status"] != "skipped")
.map(|result| result["rule"].as_str().unwrap())
.collect();
assert_eq!(active, ["tagged-content", "tagged-annotations", "tagged-form-fields"]);
let fields = found["results"].as_array().unwrap().iter().find(|result| result["rule"] == "tagged-form-fields").unwrap();
assert_eq!(fields["status"], "failed");
assert_eq!(fields["findings"][0]["page"], 1);
}
#[test]
fn a_missing_path_under_the_root_names_where_it_was_looked_for() {
// #1890: a script written from another working directory repeats the root's folder in its
+34 -11
View File
@@ -1355,13 +1355,9 @@ impl Document {
None => return Ok(None),
}
}
Err(e) => {
if sections.is_empty() {
return Err(e);
}
log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
break;
}
// An unreadable older section loses every object only it locates: the
// materialized reader logs it and the file is rebuilt from object headers (#1656).
Err(e) => return Err(e),
};
revisions.push(Revision { xref_offset: off, is_stream });
let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) };
@@ -1649,11 +1645,12 @@ impl Document {
self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)?
}
Err(e) => {
if revisions.is_empty() {
return Err(e);
// An unreadable older section loses every object only it locates (the page
// tree, often): rebuild from object headers, as readers do (#1656).
if !revisions.is_empty() {
log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
}
log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
break;
return Err(e);
}
};
revisions.push(Revision { xref_offset: off, is_stream });
@@ -2188,6 +2185,32 @@ mod tests {
}
}
#[test]
fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() {
// The newest section lists the catalog only; its /Prev points past the end of the file,
// so the page tree is reachable only by scanning object headers, as readers do (#1656).
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (num, body) in
[(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")]
{
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n",
offsets[0]
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice()));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice()));
assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log());
}
#[test]
fn self_referential_length_does_not_overflow() {
let bytes = build(
+5
View File
@@ -501,6 +501,11 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] {
[r[0], r[3] - h, r[2], r[3]]
}
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
pub fn text_width(t: &AddedText) -> f64 {
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
}
fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
}
+24
View File
@@ -3431,6 +3431,30 @@ fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() {
assert_eq!(blocks[2].text, "Other column");
}
#[test]
fn a_single_row_with_mixed_styles_grows_before_wrapping() {
let mut doc = text_page("BT /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf (bold) Tj /F1 10 Tf ( text.) Tj ET");
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| {
*f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold"));
})
.unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
let index = blocks.iter().position(|b| b.editor_text == "Plain bold text.").unwrap();
let source = &blocks[index];
assert_eq!(source.rows.len(), 1);
assert!(source.lines.len() > 1);
let draft = "Plain bold text. More words on the same line";
text::replace_block(&mut doc, 0, index, draft).unwrap();
let edited = text::text_blocks(&reopen(&doc), 0).unwrap();
let block = edited.iter().find(|b| b.text == draft).unwrap();
assert_eq!(block.rows.len(), 1, "a style boundary must not pin a single row to its original width");
assert!((block.rect[0] - source.rect[0]).abs() < 0.1);
assert!(block.rect[2] > source.rect[2] + 20.0);
}
#[test]
fn explicit_editor_rows_survive_single_run_edit_and_reopen() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET");
+3 -2
View File
@@ -379,8 +379,9 @@ pub(super) fn rewrite_styled_block(
if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) {
return Err(EditError::Invalid("the paragraph's position and width must be finite".into()));
}
// A rewritten OCR line grows to the right rather than wrapping onto the scanned line below.
let natural = if block.invisible {
// A visual row grows even when it contains several source/font runs. Multi-row
// paragraphs retain their container width; an explicit width always wins.
let natural = if block.invisible || block.rows.len() == 1 {
(p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0])
} else {
block.rect[2] - block.rect[0]
+2
View File
@@ -472,8 +472,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
title: "Accessibility",
items: &[
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
item("Tag form fields", "tag", "a11y.autotag_fields", Ready),
item("Change reading options", "book-open", "a11y.reading_options", Ready),
item("Check for accessibility", "accessibility", "a11y.check", Ready),
item("Find untagged content", "search", "a11y.find_untagged", Ready),
item("Open accessibility report", "file-text", "a11y.report", Ready),
item("Add alternate text", "image", "a11y.alt_text", Ready),
item("Tags panel", "tag", "a11y.tags", Ready),
+2
View File
@@ -332,10 +332,12 @@ pub const COMMANDS: &[CommandSpec] = &[
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
c("a11y.autotag_fields", "Tag form fields", None, None, Modification, "tag"),
c("a11y.tags", "Tags panel", None, None, Document, "tag"),
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
+61 -2
View File
@@ -52,13 +52,14 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width};
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
@@ -594,6 +595,11 @@ impl Document {
self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default()
}
/// Marked-content items directly owned by one reachable indirect structure tag.
pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result<Vec<a11y::TagContentItem>, a11y::TagError> {
self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new()))
}
/// The edit that fixes `rule`, for the rules with an automatic fix: the document language
/// (`value` is the language), the title (`value` replaces it; else the current title or the
/// file name) and the tab order.
@@ -869,6 +875,12 @@ fn is_placeholder_title(title: &str) -> bool {
/// What is displayed: the working file, plus (in memory only, never saved) the appearances
/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]).
/// The working file is unchanged, and so is everything that reads it (saving, signatures, …).
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation)
}
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else {
return bytes.clone();
@@ -1471,6 +1483,8 @@ pub enum Edit {
figure: u32,
alt: Option<String>,
},
/// Add `/Form` tags and object references for all widgets in an untagged AcroForm.
AutoTagFormFields,
/// Change one property on a reachable indirect structure tag.
SetTagProperty {
object: pdfcraft_cos::ObjRef,
@@ -1961,6 +1975,7 @@ impl Edit {
Edit::SetInitialView(_) => Label::of("Change initial view"),
Edit::AutoTagText => Label::of("Automatically tag text"),
Edit::SetAltText { .. } => Label::of("Set alternate text"),
Edit::AutoTagFormFields => Label::of("Tag form fields"),
Edit::SetTagProperty { .. } => Label::of("Change tag properties"),
Edit::MoveTag { .. } => Label::of("Move object"),
Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"),
@@ -2156,6 +2171,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::SetInitialView(_)
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::AutoTagFormFields
| Edit::SetTagProperty { .. }
| Edit::MoveTag { .. }
| Edit::MarkDecorative { .. }
@@ -2444,6 +2460,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::AutoTagFormFields => {
a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?;
}
Edit::SetTagProperty { object, property, value } => {
a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
@@ -3439,6 +3458,31 @@ impl Session {
},
(_, cos) => (bytes, info, cos),
};
// The catalog leads to no page, but the reader found pages by scanning: gather the page
// objects into a page tree as one more revision, or page edits address none of them
// (#1656). Kept only when the reader then shows the same pages, and never on a signed
// document, where writing the repair could rewrite the file under its signatures.
let (bytes, info, cos) = match cos {
Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => {
let mut work = cos.clone();
let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed);
let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) {
Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)),
_ => None,
};
match rebuilt {
Some((b, i, mut c)) => {
for line in cos.repair_log() {
c.note_repair(line.clone());
}
c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len()));
(b, i, Ok(Ok(c)))
}
None => (bytes, info, Ok(Ok(cos))),
}
}
cos => (bytes, info, cos),
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
@@ -3677,8 +3721,9 @@ impl Session {
}
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
// So does any edit to a file rebuilt from object headers: it has no chain to append to.
let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed();
if signed && rewrites(&next) && !rewrites(&editor.cos) {
if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) {
return Err(EditError::SignedRewrite(edit.label().to_string()));
}
// What this edit drew with `?` (a value or comment text its font can't draw, stored as
@@ -4290,6 +4335,20 @@ impl Session {
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
}
/// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]);
/// only `pages` are read, the others are `false`.
pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec<bool> {
let Some(doc) = self.get(id) else { return Vec::new() };
let cos = match doc.editor.as_ref() {
Some(e) => e.cos.clone(),
None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) {
Ok(c) => c,
Err(_) => return Vec::new(),
},
};
(0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect()
}
/// Combine whole files, in order, into new PDF bytes (one bookmark per file).
pub fn combine(&self, sources: &[(String, Arc<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {
let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
+44
View File
@@ -5126,6 +5126,50 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
assert!(d.page_image_copy_file(0, 1).is_err());
}
/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656).
fn fixture_without_page_tree() -> Vec<u8> {
let bytes = fixture(2);
let catalog = b"<< /Type /Catalog /Pages 2 0 R >>";
let at = bytes.windows(catalog.len()).position(|w| w == catalog).unwrap();
let mut out = bytes.clone();
out[at..at + catalog.len()].copy_from_slice(b"<< /Type /Catalog /Pages 9 0 R >>");
out
}
#[test]
fn a_recovered_page_tree_opens_unmodified_and_edits_the_pages_shown() {
let mut s = Session::new();
let id = s.open("damaged.pdf", None, Arc::new(fixture_without_page_tree()), None).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(!doc.editor.as_ref().unwrap().cos.is_modified(), "the repair is a revision of the opened file, not an unsaved edit");
assert!(doc.repair_log().iter().any(|l| l.contains("page tree is missing")), "{:?}", doc.repair_log());
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>(), [0, 90]);
}
#[test]
fn a_signed_document_rebuilt_from_object_headers_refuses_edits() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap();
let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed, None).unwrap();
// A broken startxref: the file opens only by rebuilding from object headers, and any save
// of it is a full rewrite.
let mut bytes = s.get(id).unwrap().bytes.as_ref().clone();
let sx = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let end = sx + bytes[sx..].iter().position(|b| !b.is_ascii_digit()).unwrap();
bytes.splice(sx..end, b"1".iter().copied());
let id = s.open("damaged.pdf", None, Arc::new(bytes), None).unwrap();
assert!(s.get(id).unwrap().is_signed());
assert!(s.get(id).unwrap().editor.as_ref().unwrap().cos.revisions().is_empty(), "the fixture must be rebuilt");
assert!(matches!(s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])), Err(EditError::SignedRewrite(_))));
assert!(!s.get(id).unwrap().dirty);
}
/// #1324: marking a figure decorative rewrites that figure only. The header, added text and an
/// unknown entry on the original stream stay attached to the page. This lives in the engine tests
/// because edit and a11y are the same layer and cannot depend on each other.
+14
View File
@@ -33,6 +33,20 @@ impl TextEditPreview {
}
impl Document {
/// Background for retyping an added text item, without changing its saved content.
pub fn added_text_preview(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
guard(|| {
if !self.added().iter().filter(|a| a.page == page).nth(index).is_some_and(|a| matches!(a.content, crate::AddedContent::Text(_))) {
return Err("the selected added item is not text".into());
}
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
pdfcraft_edit::delete_content(&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(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
/// Measure a draft without changing bytes, dirty state, or undo history.
pub fn text_edit_layout(
&self,
+113 -8
View File
@@ -318,6 +318,93 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
(c, 0.0)
}
/// The width [`frame`] insets the content by: the `/BS` border width when the widget has a
/// border colour, doubled for beveled and inset borders, and 0 without a border.
fn border_width(doc: &Document, wd: &Dict) -> f64 {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 {
return 0.0;
}
let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default();
if style == b"B" || style == b"I" { 2.0 * bw } else { bw }
}
/// Where a list box draws its options: one row per option from the top, starting with option
/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share
/// it, so a click picks the option drawn under the pointer (#1524).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ListRows {
/// The first option shown (`/TI`).
pub top: usize,
/// From the widget's top edge to the first row, in points.
pub inset: f64,
/// The height of one row, in points.
pub row: f64,
/// The text size, in points.
pub size: f64,
}
impl ListRows {
/// How many whole rows fit in a list box `height` points tall (at least one).
pub fn visible(&self, height: f64) -> usize {
if !(height.is_finite() && self.row > 0.0) {
return 1;
}
let rows = ((height - self.inset) / self.row).floor();
if rows >= 1.0 { rows.min(10_000.0) as usize } else { 1 }
}
/// The option at `y` points below the widget's top edge when the list starts at option `top`,
/// or `None` above the first row. The caller checks it against the number of options.
pub fn option_at(&self, top: usize, y: f64) -> Option<usize> {
if !(y.is_finite() && self.row > 0.0) || y < self.inset {
return None;
}
let k = ((y - self.inset) / self.row).floor();
(k < 1.0e6).then(|| top.saturating_add(k as usize))
}
/// The top index that shows as many of the `selected` option indices as fit in `visible`
/// rows, staying at `current` when they are all shown already, among `count` options.
pub fn top_showing(current: usize, selected: &[usize], visible: usize, count: usize) -> usize {
let visible = visible.max(1);
let last_top = count.saturating_sub(visible);
let (Some(&first), Some(&last)) = (selected.iter().min(), selected.iter().max()) else { return current.min(last_top) };
let top = if first >= current && last < current.saturating_add(visible) {
current
} else if last.saturating_sub(first) >= visible || first < current {
first
} else {
// Scroll down just far enough that the last selected option is the bottom row.
last.saturating_add(1).saturating_sub(visible)
};
top.min(last_top)
}
}
/// `/TI` of a list box: on the widget, else on the field.
fn top_index(doc: &Document, wd: &Dict, field: Option<&Dict>) -> usize {
let ti = |d: &Dict| d.get(b"TI").and_then(|o| doc.resolve(o).as_int());
ti(wd).or_else(|| field.and_then(ti)).and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
}
/// The rows of a list box whose field's `/DA` is `f_da`, in widget `wd`.
pub(crate) fn list_rows_of(doc: &Document, f_da: &str, field: Option<&Dict>, wd: &Dict) -> ListRows {
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da));
let size = if da.size > 0.0 { da.size } else { 12.0 };
ListRows { top: top_index(doc, wd, field), inset: 2.0 + border_width(doc, wd), row: size * 1.15, size }
}
/// The rows of list box `f` as drawn in widget `w`.
pub fn list_rows(doc: &Document, f: &Field, w: &Widget) -> ListRows {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let fobj = doc.get(f.obj);
list_rows_of(doc, &f.da, fobj.as_dict(), &wd)
}
/// The appearance of a text or choice field's widget showing `values`. Characters its font can't
/// hold come out as `?`; [`field_appearance_drawn`] says which.
pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream {
@@ -400,14 +487,13 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
let mut size = da.size;
match f.kind {
FieldKind::List => {
// Every option, one per line from the top; selected ones highlighted.
if size == 0.0 {
size = 12.0;
}
let line = size * 1.15;
let top = wd.get(b"TI").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).max(0) as usize;
let mut y = height - pad;
for (export, display) in f.options.iter().skip(top) {
// Every option, one per line from the top (from `/TI`); selected ones highlighted.
let fobj = doc.get(f.obj);
let rows = list_rows_of(doc, &f.da, fobj.as_dict(), &wd);
size = rows.size;
let line = rows.row;
let mut y = height - rows.inset;
for (export, display) in f.options.iter().skip(rows.top) {
if y - line < 0.0 {
break;
}
@@ -618,4 +704,23 @@ mod tests {
assert_eq!(parse_da("garbage").font, "Helv");
assert_eq!(stroke_op("1 0 0 rg"), "1 0 0 RG");
}
#[test]
fn list_rows_visibility_and_scrolling() {
let rows = ListRows { top: 0, inset: 2.0, row: 11.5, size: 10.0 };
assert_eq!(rows.visible(46.0), 3);
assert_eq!(rows.option_at(0, 1.0), None);
assert_eq!(rows.option_at(0, 5.0), Some(0));
assert_eq!(rows.option_at(0, 15.0), Some(1));
assert_eq!(rows.option_at(2, 5.0), Some(2));
// When Tue (1) and Thu (3) are selected in 5 options with 3 visible rows:
assert_eq!(ListRows::top_showing(0, &[1, 3], 3, 5), 1);
// When staying within view:
assert_eq!(ListRows::top_showing(1, &[1, 2], 3, 5), 1);
// When selecting before current top:
assert_eq!(ListRows::top_showing(2, &[0], 3, 5), 0);
// When empty selection:
assert_eq!(ListRows::top_showing(0, &[], 3, 5), 0);
}
}
+1
View File
@@ -657,6 +657,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
locked: false,
hidden: false,
rotation: 0,
list: None,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
+28
View File
@@ -103,6 +103,8 @@ pub struct Widget {
pub hidden: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option<appearance::ListRows>,
}
/// A terminal form field.
@@ -800,6 +802,7 @@ fn walk(
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
})
})
.collect();
@@ -1053,6 +1056,16 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
// /I lists option indices in ascending order; /V keeps the caller's value order.
// Every entry above is an integer, so this sorts by its numeric option index.
indices.sort_unstable_by_key(Object::as_int);
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
let top = match (f.kind, f.widgets.first()) {
(FieldKind::List, Some(w)) => w.list.map(|rows| {
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
}),
_ => None,
};
doc.update_dict(f.obj, |d| {
match exports.as_slice() {
[] => {
@@ -1066,7 +1079,22 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
} else {
d.set(b"I".to_vec(), Object::Array(indices));
}
if let Some((old, new)) = top
&& old != new
{
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
}
})?;
// A widget with its own `/TI` (read before the field's) scrolls too.
if let Some((old, new)) = top
&& old != new
{
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
}
}
}
redraw(doc, f, &exports, scripts)
}
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
+27
View File
@@ -607,6 +607,33 @@ fn choices_accept_exports_or_display_text() {
assert!(matches!(set_value(&mut doc, "country", &FieldValue::Choice(vec!["ca".into(), "fr".into()])), Err(FormError::Invalid(_))));
}
#[test]
fn list_box_selection_scrolls_to_show_selected_options() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(),
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (days) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 700 200 746] /P 3 0 R >>"
.into(),
];
let mut doc = document(&objs);
// Setting value to Tue and Thu (indices 1 and 3) in a 46 pt high box (fits 3 rows).
// It should scroll /TI to 1 so Tue (1), Wed (2), Thu (3) are drawn and highlighted.
set_value(&mut doc, "days", &FieldValue::Choice(vec!["Tue".into(), "Thu".into()])).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
let days = field(&all, "days");
assert_eq!(days.value, ["Tue", "Thu"]);
let ti = doc.get(days.obj).as_dict().unwrap().get(b"TI").and_then(|o| o.as_int());
assert_eq!(ti, Some(1), "TI scrolled to 1 so both selected options are visible");
let appearance = ap(&doc, &days.widgets[0]);
assert!(appearance.contains("(Tue) Tj"), "Tue is drawn: {appearance}");
assert!(appearance.contains("(Thu) Tj"), "Thu is drawn: {appearance}");
assert_eq!(appearance.matches("0.6 0.75 0.86 rg").count(), 2, "both Tue and Thu highlighted");
}
#[test]
fn multiselect_choice_indices_are_sorted_without_reordering_values() {
let mut forward_appearance = None;
+37 -1
View File
@@ -11,7 +11,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry};
mod boxes;
mod dedupe;
@@ -148,8 +148,44 @@ fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeE
Ok(())
}
/// Gather the document's `/Type /Page` objects into a new page tree when the catalog leads to no
/// page (its `/Pages` is missing or points at a lost object). Readers then find the pages by
/// scanning the file, in file order, and edits must address those same pages. Returns how many
/// pages were gathered; 0 leaves the document unchanged.
pub fn recover_page_tree(doc: &mut Document) -> Result<usize, OrganizeError> {
if walk(doc).is_ok_and(|pages| !pages.is_empty()) {
return Ok(0);
}
let catalog = doc.root().ok_or(OrganizeError::NoPageTree)?;
let position = |num: u32| match doc.xref_entry(num) {
Some(XrefEntry::InFile { offset, .. }) => (offset, 0),
Some(XrefEntry::InStream { stream, index }) => match doc.xref_entry(stream) {
Some(XrefEntry::InFile { offset, .. }) => (offset, index),
_ => (u64::MAX, index),
},
_ => (u64::MAX, 0),
};
let mut found: Vec<((u64, u32), ObjRef)> = doc
.scan_objects()
.filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Page")))
.map(|(r, _)| (position(r.num), r))
.collect();
if found.is_empty() {
return Ok(0);
}
found.sort();
let root = doc.add(Dict::new());
doc.update_dict(catalog, |d| d.set(b"Pages".to_vec(), Object::Ref(root)))?;
let order: Vec<(ObjRef, Dict)> = found.into_iter().map(|(_, r)| (r, Dict::new())).collect();
rebuild(doc, &order)?;
Ok(order.len())
}
fn check(indices: &[usize], n: usize) -> Result<(), OrganizeError> {
match indices.iter().find(|i| **i >= n) {
// A viewer still shows pages a lost page tree leaves out (#1656): "page 1 does not
// exist" would contradict it.
Some(_) if n == 0 => Err(OrganizeError::Invalid("the document's page tree lists no pages: the file is damaged".into())),
Some(i) => Err(OrganizeError::NoSuchPage(*i)),
None => Ok(()),
}
+128 -1
View File
@@ -179,6 +179,11 @@ pub enum Layout {
overlap: f64,
cut_marks: bool,
},
/// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads
/// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°.
Label {
flip: bool,
},
}
impl Default for Layout {
@@ -228,6 +233,10 @@ pub struct Settings {
pub orientation: Orientation,
pub layout: Layout,
pub content: Content,
/// For [`Layout::Label`], per source page: the area to keep (display space) and whether its
/// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a
/// missing entry keeps the whole page.
pub crops: Vec<Option<Crop>>,
/// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this
/// resolution before it is placed on the paper. The engine does the rendering; `impose` is
/// then given the image-only document.
@@ -249,6 +258,12 @@ impl Default for Raster {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Crop {
pub rect: [f64; 4],
pub text_up: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
@@ -258,12 +273,13 @@ impl Default for Settings {
layout: Layout::default(),
content: Content::default(),
raster: None,
crops: Vec::new(),
}
}
}
/// Common paper sizes (points, portrait).
pub const PAPERS: [(&str, (f64, f64)); 19] = [
pub const PAPERS: [(&str, (f64, f64)); 20] = [
("US Letter", (612.0, 792.0)),
("US Legal", (612.0, 1008.0)),
("Tabloid", (792.0, 1224.0)),
@@ -283,8 +299,91 @@ pub const PAPERS: [(&str, (f64, f64)); 19] = [
("A2", (1190.55, 1683.78)),
("A1", (1683.78, 2383.94)),
("A0", (2383.94, 3370.39)),
("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)),
];
/// `PAPERS` index of the 4 × 6 in shipping label.
pub const LABEL_PAPER: usize = 19;
/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out
/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label).
pub fn cups_media(index: usize) -> Option<&'static str> {
match PAPERS.get(index)?.0 {
"US Letter" => Some("Letter"),
"US Legal" => Some("Legal"),
"Tabloid" => Some("Tabloid"),
"A3" => Some("A3"),
"A4" => Some("A4"),
"A5" => Some("A5"),
_ if index == LABEL_PAPER => Some("w288h432"),
_ => None,
}
}
/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid
/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects
/// are followed a few levels deep.
pub fn text_reads_up(doc: &Document, index: usize) -> bool {
let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false };
let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default();
let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut score = 0i64;
text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score);
score > 0
}
fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) {
let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new());
for op in pdfcraft_content::parse(data).ops {
match op.op.as_slice() {
// Bounded like the renderer's graphics-state stack: deeper saves are ignored.
b"q" if stack.len() < 256 => stack.push(ctm),
b"Q" => ctm = stack.pop().unwrap_or(base),
b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm),
b"BT" => tm = Matrix::IDENTITY,
b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(),
b"Tj" | b"TJ" | b"'" | b"\"" => {
let [a, b, ..] = tm.then(&ctm).0;
*score += i64::from(b > a.abs()) - i64::from(-b > a.abs());
}
b"Do" if depth < 4 => {
let Some(name) = op.name(0) else { continue };
let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue };
let Object::Stream(form) = &*doc.resolve(&xobj) else { continue };
if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) {
continue;
}
let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default();
let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone());
if let Ok(body) = form.decoded() {
text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score);
}
}
_ => {}
}
}
}
/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed
/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet.
pub const LABEL_MARGIN: f64 = 8.0;
/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by
/// `quarter` quarter turns counter-clockwise (0–3).
fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement {
let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]);
let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) };
let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0);
let [a, b, c, d, e, f] = match quarter % 4 {
0 => [s, 0.0, 0.0, s, x, y],
1 => [0.0, s, -s, 0.0, x + pw, y],
2 => [-s, 0.0, 0.0, -s, x + pw, y + ph],
_ => [0.0, -s, s, 0.0, x, y + ph],
};
// Move the crop's corner to the origin first.
Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop }
}
/// Match a displayed PDF page to a standard sheet, independent of orientation.
/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes.
pub fn matching_paper(size: (f64, f64)) -> Option<usize> {
@@ -373,6 +472,34 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() });
}
}
Layout::Label { flip } => {
let size = oriented(paper, settings.orientation, false);
let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN];
for &p in &pages {
let d = sizes[p];
let crop = settings.crops.get(p).copied().flatten();
let rect = crop
.map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)])
.filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0)
.unwrap_or([0.0, 0.0, d.0, d.1]);
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]);
let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) {
(false, _) => 0,
(true, true) => 3,
(true, false) => 1,
};
if flip {
quarter = (quarter + 2) % 4;
}
let s = if turn {
fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1]))
} else {
fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1]))
};
sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() });
}
}
Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => {
let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256);
let Some(cells) = cells else {
+51
View File
@@ -644,6 +644,57 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes()
}
}
#[test]
fn label_is_cut_turned_upright_and_fitted_without_distortion() {
let label = LABEL_PAPER;
assert_eq!(PAPERS[label].0, "4 x 6 in label (101.6 x 152.4 mm)");
assert_eq!(PAPERS[label].1, (288.0, 432.0));
assert_eq!(cups_media(label), Some("w288h432"));
// A sideways label in the lower half of an A4 sheet, text running bottom-to-top.
let rect = [40.0, 30.0, 560.0, 420.0];
for (text_up, flip, want_text_dir) in [(true, false, (1.0, 0.0)), (false, false, (1.0, 0.0)), (true, true, (-1.0, 0.0))] {
// Text direction on the page: up when text_up, down otherwise.
let page_dir = if text_up { (0.0, 1.0) } else { (0.0, -1.0) };
let s = Settings {
pages: vec![0],
paper: PAPERS[label].1,
layout: Layout::Label { flip },
crops: vec![Some(Crop { rect, text_up })],
..Settings::default()
};
let sheets = layout(&[(595.0, 842.0)], &s).unwrap();
assert_eq!(sheets.len(), 1);
assert_eq!(sheets[0].size, (288.0, 432.0));
let pl = sheets[0].placed[0];
assert_eq!(pl.clip, rect);
let [a, b, c, d, _, _] = pl.matrix.0;
// A pure quarter turn with one scale: no stretching.
assert!((a.hypot(b) - c.hypot(d)).abs() < 1e-9 && (a * c + b * d).abs() < 1e-9);
let dir = (a * page_dir.0 + c * page_dir.1, b * page_dir.0 + d * page_dir.1);
let n = dir.0.hypot(dir.1);
assert!((dir.0 / n - want_text_dir.0).abs() < 1e-9 && (dir.1 / n - want_text_dir.1).abs() < 1e-9, "text_up={text_up} flip={flip}");
let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])].map(|(x, y)| pl.matrix.apply(x, y));
let (x0, x1) = corners.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.0), m.1.max(p.0)));
let (y0, y1) = corners.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));
assert!(x0 >= LABEL_MARGIN - 1e-6 && x1 <= 288.0 - LABEL_MARGIN + 1e-6 && y0 >= LABEL_MARGIN - 1e-6 && y1 <= 432.0 - LABEL_MARGIN + 1e-6);
// Fills the label's width (the crop's height, 390 pt, becomes it).
assert!((x1 - x0 - (288.0 - 2.0 * LABEL_MARGIN)).abs() < 1e-6);
}
// No crop: the whole page, upright.
let whole =
layout(&[(200.0, 300.0)], &Settings { pages: vec![0], paper: (288.0, 432.0), layout: Layout::Label { flip: false }, ..Settings::default() })
.unwrap();
assert_eq!(whole[0].placed[0].clip, [0.0, 0.0, 200.0, 300.0]);
assert_eq!(whole[0].placed[0].matrix.0[1], 0.0);
}
#[test]
fn text_direction_is_read_from_text_and_form_matrices() {
let doc = fixture(1);
assert!(!text_reads_up(&doc, 0), "upright text");
assert!(!text_reads_up(&doc, 9), "no such page");
}
#[test]
fn imposed_pages_preserve_large_user_unit_matrices() {
let (unit, mode) = (75000.0, SizeMode::Fit);
+50
View File
@@ -5617,6 +5617,56 @@ trailer << /Root 1 0 R >>
assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white");
}
/// Reading colours replace *document* colours, never annotation colours (issue #1548): a
/// highlight keeps its `/C`, so on a dark background the content under it shows through the
/// Multiply blend in the highlight's colour instead of being darkened into invisibility.
/// Both annotation paths are covered: the `/AP` appearance stream and the no-`/AP`
/// `/QuadPoints` fallback.
#[test]
fn reading_colors_keep_highlight_annotations_readable() {
let content = "0 g 100 40 10 20 re f";
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 200 100] /Contents 4 0 R /Annots [5 0 R] >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] {{annot}} >> endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] /Length 24 >> stream
0 0 1 rg 15 35 50 30 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let render = |annot: &str| {
let mut r = PageRenderer::new(
Arc::new(pdf.replace("{annot}", annot).into_bytes()),
RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() },
);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{annot}: {:?}", p.error);
p
};
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
// No /AP: the page's black rectangle is remapped to white (document line art), the
// yellow fill is not, so Multiply leaves the content under the quad yellow — readable.
let p = render("");
assert_eq!(px(&p, 5, 5), vec![0, 0, 0, 255], "the page clears to the background");
assert_eq!(px(&p, 105, 50), vec![255, 255, 0, 255], "content under the quad shows through in the highlight's colour");
assert_eq!(px(&p, 30, 50), vec![0, 0, 0, 255], "the band itself multiplies into the dark background, as in Acrobat");
assert_eq!(px(&p, 150, 50), vec![0, 0, 0, 255], "outside the quad");
// With /AP the appearance's blue is an annotation colour too and stays blue instead of
// being remapped towards white by its luminance.
let p = render("/AP << /N 6 0 R >>");
assert_eq!(px(&p, 30, 50), vec![0, 0, 255, 255], "the appearance keeps its own colour");
assert_eq!(px(&p, 90, 50), vec![0, 0, 0, 255], "outside the appearance's own drawing");
}
/// A print-preview request (`PRINT_TAG`) renders the document's true colours even though
/// the document's config carries reading colours: a preview's job is fidelity to the
/// printout, which is imposed from the uncoloured document. Covers both render paths —
+4
View File
@@ -45,6 +45,10 @@ arboard = { version = "3.6", default-features = false, features = ["image-data"]
# Waits for native file pickers off the UI thread (already in the tree through wgpu).
pollster = "1.0"
[target.'cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))'.dependencies]
# Discover configured font directories without linking libfontconfig or running a subprocess.
fontconfig-parser = "0.5.8"
[target.'cfg(target_os = "macos")'.dependencies]
# Where the pointer is while files are dragged over the window (winit doesn't say): safe AppKit
# calls only. All three are already in the tree through arboard and rfd.
+27
View File
@@ -343,6 +343,27 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D
}
impl PdfCraftApp {
/// Prepare for accessibility ▸ Find untagged content.
pub(crate) fn find_untagged_content(&mut self) {
let Some((_, id)) = self.active_ids() else { return };
let Some(doc) = self.session.get(id) else { return };
let options = Options { rules: [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect(), pages: None };
let Some(report) = doc.accessibility_check(&options) else {
self.notify_tr("This document can't be checked: it couldn't be read for editing");
return;
};
let failed = report.count(Status::Failed);
self.a11y.previous = None;
self.a11y.options = Some(options);
self.a11y.report = Some((id, report));
self.right = Some(RightPanel::Accessibility);
self.notify(if failed == 0 {
tl!("No untagged content was found.").to_string()
} else {
crate::i18n::fmt(tl!("Found {n} untagged content problems."), &[("n", &failed.to_string())])
});
}
/// Check for accessibility: the options first, unless they are turned off.
pub(crate) fn start_accessibility_check(&mut self) {
if self.a11y_options.show_dialog {
@@ -608,6 +629,12 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ->
}
impl PdfCraftApp {
/// Prepare for accessibility ▸ Tag form fields.
pub(crate) fn autotag_form_fields(&mut self) {
self.apply_edit(pdfcraft_engine::Edit::AutoTagFormFields);
self.choose_right_panel(Some(RightPanel::Tags));
}
/// Prepare for accessibility ▸ Add alternate text.
pub(crate) fn start_alt_text(&mut self) {
let Some((_, id)) = self.active_ids() else { return };
+58 -15
View File
@@ -2564,7 +2564,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.prepare.selected = None;
}
view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx());
view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), scale);
view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale);
let added = if editing_content { doc.added().to_vec() } else { Vec::new() };
let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() };
if editing_content {
@@ -2784,7 +2784,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
}
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref());
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref());
if editing_content {
let page_tex = view.pages.get(&i).map(|p| p.tex.id());
view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex);
@@ -2899,7 +2899,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
(o.consumed || field_tool.is_some(), false)
} else {
use crate::forms_ui::FieldPointer;
let field = if tool == QuickTool::Select && !comments::select_grabs(ui, &pcx, view) {
let field = if tool == QuickTool::Select
&& !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view))
&& !comments::select_grabs(ui, &pcx, view)
{
crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
} else {
FieldPointer::Away
@@ -2919,9 +2922,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view);
let on_objects =
!image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view);
let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify {
let generation = doc.edit_generation();
match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.text_blocks(i);
view.edit_lines.insert(i, (generation, l.clone()));
l
}
}
} else {
Vec::new()
};
let over_source = tool == QuickTool::AddText
&& pointer.is_some_and(|p| {
!crate::content_ui::hits_added(&xf, info, i, &added, p)
&& lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p))
});
let source_blocked = over_source && view.content.hold_blocked();
if over_source && resp.clicked() && !source_blocked {
if let Some(edit) = view.content.finish_draft(&added) {
view.pending_edit = Some(edit);
}
view.content.selected = None;
}
let on_content = !on_objects && editing_content && can_modify && {
crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view)
|| tool == QuickTool::AddText
source_blocked
|| crate::content_ui::page_input(
ui,
&resp,
&xf,
i,
info,
&added,
tool == QuickTool::AddText && !over_source,
matches!(tool, QuickTool::AddText | QuickTool::EditText),
&text_style,
view,
)
|| tool == QuickTool::AddText && !over_source
};
// Added content owns its gesture; its source text must not queue a second edit.
// Edit text & images boxes every paragraph and image whenever the tool is on. Only the
@@ -2929,16 +2969,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn
// instead of vanishing until the pointer comes back.
let others_own_pointer = on_objects || on_content;
let on_edit_text = tool == QuickTool::EditText && can_modify && {
let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && {
let generation = doc.edit_generation();
let lines = match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.text_blocks(i);
view.edit_lines.insert(i, (generation, l.clone()));
l
}
};
let images = match view.edit_images.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
@@ -3229,8 +3261,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let gesturing = view.comments.gesture.is_some() || freehand;
for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) {
let sr = xf.user_rect(info, i, a.rect);
let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty);
if sr.contains(p)
&& hover_text.is_none()
&& !hidden
&& let Some(text) = annotation_hover_text(a)
{
hover_text = Some((p, text));
@@ -3701,8 +3735,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
match image_action {
Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index),
Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => app.copy_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => {
app.copy_page_image(page, index);
}
Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index),
Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => {
app.views[index].image_selection = None;
app.cut_page_image(page, image);
}
Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page),
None => {}
}
if let Some(why) = refused {
+2
View File
@@ -395,6 +395,7 @@ impl PdfCraftApp {
"export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)),
"export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)),
"a11y.check" => self.start_accessibility_check(),
"a11y.find_untagged" => self.find_untagged_content(),
"ocr.recognize" => self.dialog = Some(Dialog::RecognizeText),
"tools.js_console" => self.dialog = Some(Dialog::JsConsole),
"tools.document_js" => {
@@ -424,6 +425,7 @@ impl PdfCraftApp {
}
}
"a11y.alt_text" => self.start_alt_text(),
"a11y.autotag_fields" => self.autotag_form_fields(),
"a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)),
"a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)),
"export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)),
+5
View File
@@ -250,6 +250,8 @@ pub struct CommentPrefs {
pub pinned: bool,
/// The tool each flyout group shows (the last one used).
pub group_tool: [CommentTool; 3],
/// Hide hover previews on highlight annotations when they contain no comment text beyond the author.
pub hide_empty_highlight_hover: bool,
}
impl Default for CommentPrefs {
@@ -259,6 +261,7 @@ impl Default for CommentPrefs {
styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(),
pinned: false,
group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink],
hide_empty_highlight_hover: false,
}
}
}
@@ -478,6 +481,8 @@ pub struct CommentView {
pub hidden_checks: Vec<String>,
/// How the panel orders comments.
pub sort: SortBy,
/// Pages whose group is folded in the panel (when sorted by page).
pub collapsed_pages: Vec<usize>,
/// Comment Properties was asked for from the panel: (page, index).
pub props_request: Option<(usize, usize)>,
/// Make Current Properties Default was asked for: (page, index).
+50 -9
View File
@@ -199,6 +199,13 @@ pub(crate) fn show(
SortBy::Date => None,
}
};
// A comment selected elsewhere (on the page, or just added) opens its folded group, or the
// card it should scroll to would not be drawn.
if view.comments.reveal
&& let Some((p, _)) = view.comments.selected
{
view.comments.collapsed_pages.retain(|&c| c != p);
}
let mut page = usize::MAX;
let mut group: Option<String> = None;
for a in roots {
@@ -215,18 +222,38 @@ pub(crate) fn show(
} else if a.page != page {
page = a.page;
let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count();
let open = !view.comments.collapsed_pages.contains(&page);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add(icons::image("chevron-down", 14.0, t.text_muted));
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)]))
.font(theme::semibold(12.5))
.color(t.text_muted),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
});
let clicked = ui
.horizontal(|ui| {
let chevron = if open { "chevron-down" } else { "chevron-right" };
let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked();
let label = ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)]))
.font(theme::semibold(12.5))
.color(t.text_muted),
)
.sense(Sense::click()),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())
});
toggle || label.clicked()
})
.inner;
if clicked {
if open {
view.comments.collapsed_pages.push(page);
} else {
view.comments.collapsed_pages.retain(|&c| c != page);
}
}
ui.add_space(2.0);
}
if sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page) {
continue;
}
let thread = replies_of(a);
if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) {
edit = Some(e);
@@ -519,6 +546,20 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV
ui.close();
}
}
// Only the page sort has folding groups.
if cv.sort == SortBy::Page {
ui.separator();
if ui.button(tl!("Collapse all pages")).clicked() {
cv.collapsed_pages = info.annotations.iter().map(|a| a.page).collect();
cv.collapsed_pages.sort_unstable();
cv.collapsed_pages.dedup();
ui.close();
}
if ui.button(tl!("Expand all pages")).clicked() {
cv.collapsed_pages.clear();
ui.close();
}
}
ui.separator();
for (id, label) in [
("comment.hide_all", if hidden { tl!("Show all comments") } else { tl!("Hide all comments") }),
+125 -21
View File
@@ -86,9 +86,13 @@ pub struct ContentView {
pub select_added: Option<(usize, usize)>,
grab: Option<(usize, Grab, Pos2)>,
undrawable: UndrawableMemo,
layout: Option<(AddedText, f32, f64, [f64; 4])>,
}
impl ContentView {
pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option<Edit> {
self.draft.take().and_then(|d| finish(self, d, added))
}
/// The draft's first character the standard fonts can't draw. Such a draft is kept open, and
/// counts as unsaved work, until it is corrected or discarded.
pub(crate) fn blocked(&self) -> Option<char> {
@@ -248,6 +252,10 @@ fn on_page(added: &[Added], page: usize) -> Vec<(usize, &Added)> {
added.iter().filter(|a| a.page == page).enumerate().collect()
}
pub(crate) fn hits_added(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], point: Pos2) -> bool {
on_page(added, page).iter().any(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(point))
}
fn handles(r: Rect) -> [(Pos2, bool, bool); 4] {
[(r.left_top(), true, true), (r.right_top(), false, true), (r.left_bottom(), true, false), (r.right_bottom(), false, false)]
}
@@ -279,6 +287,22 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
}
}
/// Added text and images keep pointer ownership over a form field while editing content.
pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
let cv = &view.content;
if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) {
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else {
return false;
};
on_page(added, page).into_iter().any(|(index, item)| {
let rect = screen_rect(xf, info, page, item.content.rect());
rect.expand(2.0).contains(p)
|| cv.selected == Some((page, index)) && handles(rect).into_iter().any(|(corner, _, _)| corner.distance(p) <= 6.0)
})
}
/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it
/// used the input.
#[allow(clippy::too_many_arguments)]
@@ -290,6 +314,7 @@ pub(crate) fn page_input(
info: &DocInfo,
added: &[Added],
adding_text: bool,
edit_on_click: bool,
style: &AddedText,
view: &mut DocView,
) -> bool {
@@ -347,14 +372,10 @@ pub(crate) fn page_input(
let at = to_display(xf, info, page, p);
let mut style = style.clone();
style.text.clear();
cv.draft = Some(TextDraft {
page,
index: None,
rect: [at[0], at[1] - style.size * 1.2, at[0] + 200.0, at[1]],
text: String::new(),
style,
focus: true,
});
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]];
style.rect = rect;
cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true });
consumed = true;
if typed.is_some() {
view.pending_edit = typed;
@@ -379,13 +400,19 @@ pub(crate) fn page_input(
}
return consumed;
}
if resp.double_clicked()
if (resp.double_clicked() || edit_on_click && resp.clicked() && corner.is_none())
&& let Some((i, a)) = hit
&& let AddedContent::Text(t) = &a.content
{
if cv.hold_blocked() {
return true;
}
if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) {
return true;
}
if let Some(edit) = cv.finish_draft(added) {
view.pending_edit = Some(edit);
}
cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true });
cv.selected = Some((page, i));
consumed = true;
@@ -462,16 +489,46 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) {
/// committed (a click away, or Done), and whether Done finished adding text. Discard or Escape
/// throws the draft away.
pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option<Edit>, bool) {
let Some((page, rect)) = view.content.draft.as_ref().map(|d| (d.page, d.rect)) else { return (None, false) };
let Some(page) = view.content.draft.as_ref().map(|d| d.page) else { return (None, false) };
let Some(xf) = view.page_xform(page) else { return (None, false) };
let r = screen_rect(&xf, info, page, rect);
let zoom = xf.rect.width() / xf.pw.max(1.0);
let Some(draft) = view.content.draft.as_mut() else { return (None, false) };
let font = crate::edit_text_ui::editor_font(&draft.style, (draft.style.size as f32 * zoom).clamp(1.0, 300.0));
let text = AddedText { text: draft.text.clone(), ..draft.style.clone() };
let Some(page_width) = info.pages.get(page).map(|p| p.width as f64) else { return (None, false) };
if let Some((key, z, width, rect)) = &view.content.layout
&& *key == text
&& *z == zoom
&& *width == page_width
{
draft.rect = *rect;
} else {
let source = draft.style.rect;
if draft.index.is_some() && draft.text == draft.style.text {
draft.rect = source;
} else {
let natural = ctx.fonts_mut(|f| f.layout_no_wrap(draft.text.clone(), font.clone(), Color32::BLACK).size().x) as f64 / zoom as f64;
let width = natural.max(pdfcraft_engine::added_text_width(&text)) + 2.0;
let available = page_width - source[0];
let width = width.max(source[2] - source[0]).min(available.max(1.0));
draft.rect = [source[0], source[1], source[0] + width, source[3]];
draft.rect = pdfcraft_engine::added_text_rect(&AddedText { rect: draft.rect, ..text.clone() });
}
view.content.layout = Some((text, zoom, page_width, draft.rect));
}
let r = screen_rect(&xf, info, page, draft.rect);
let source_rect = draft.index.map(|_| screen_rect(&xf, info, page, draft.style.rect));
let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index));
let tokens = Tokens::get(ctx);
let (mut commit, mut cancel, mut done) = (false, false, false);
let mut undrawable = None;
let memo = &view.content.undrawable;
egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| {
egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).constrain(false).show(ctx, |ui| {
let Some(t) = view.content.draft.as_mut() else { return };
ui.set_width(r.width() + 64.0);
if let Some(source_rect) = source_rect.filter(|_| !background_ready) {
ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE);
}
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
@@ -479,10 +536,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
ui.spacing_mut().item_spacing.x = 4.0;
let resp = ui.add(
egui::TextEdit::multiline(&mut t.text)
.font(egui::FontId::proportional((t.style.size as f32 * zoom).max(8.0)))
.font(font)
.desired_width(r.width().max(60.0))
.desired_rows(1)
.background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 235))
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
.id_salt("added-text-edit"),
@@ -542,7 +599,8 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option<Edit> {
Some(i) => {
let (_, a) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i)?;
let AddedContent::Text(old) = &a.content else { return None };
(old.text != text).then(|| Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(AddedText { text, ..old.clone() }) })
let updated = AddedText { text, rect: d.rect, ..d.style };
(*old != updated).then_some(Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(updated) })
}
}
}
@@ -672,12 +730,12 @@ impl crate::PdfCraftApp {
}
/// Copy Image: image `index` on `page`, at its own resolution, to the clipboard.
pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) {
let Some((_, id)) = self.active_ids() else { return };
pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) -> bool {
let Some((_, id)) = self.active_ids() else { return false };
if !self.may_copy(id) {
return;
return false;
}
let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return };
let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return false };
let copied = pixels.and_then(|(w, h, rgba)| {
#[cfg(not(target_arch = "wasm32"))]
if self.system_clipboard {
@@ -692,11 +750,57 @@ impl crate::PdfCraftApp {
Ok((w, h))
});
match copied {
Ok((w, h)) => self.notify_fmt("The image has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]),
Err(e) => self.notify_fmt("Couldn't copy the image: {e}", &[("e", &e)]),
Ok((w, h)) => {
self.notify_fmt("The image has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]);
true
}
Err(e) => {
self.notify_fmt("Couldn't copy the image: {e}", &[("e", &e)]);
false
}
}
}
/// The picture's visible box on its page, in user space.
fn page_image_box(&self, page: usize, index: usize) -> Option<[f64; 4]> {
let (_, id) = self.active_ids()?;
let image = self.session.get(id)?.page_images(page).into_iter().nth(index)?;
Some(image.visible_rect.unwrap_or(image.rect))
}
/// Cut: copy the picture, and only when that worked take it off the page (one undo step).
pub(crate) fn cut_page_image(&mut self, page: usize, index: usize) {
if self.copy_page_image(page, index) {
self.apply_edit(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete });
}
}
/// Redact image: a redaction mark over the whole picture. It is applied, like any mark, from
/// Redact a PDF.
pub(crate) fn redact_page_image(&mut self, page: usize, index: usize) {
let Some(rect) = self.page_image_box(page, index) else { return };
let author = self.comment_prefs.author.clone();
let mark = self.redact_prefs.mark(page, vec![pdfcraft_engine::rect_quad(rect)], &author);
if self.apply_edit(mark) {
self.notify_tr("The image is marked for redaction. Apply it with Redact a PDF");
}
}
/// Create link: the link properties for a new link over the picture.
pub(crate) fn link_page_image(&mut self, page: usize, index: usize) {
if let Some(rect) = self.page_image_box(page, index) {
self.open_link_props(page, Some(rect), None);
}
}
/// Recognize text: the Recognize Text dialog, set to this page.
pub fn recognize_text_on_page(&mut self, page: usize) {
self.ocr_draft.pages = crate::ocr_ui::OcrPages::Range;
self.ocr_draft.from = page + 1;
self.ocr_draft.to = page + 1;
self.dialog = Some(crate::Dialog::RecognizeText);
}
/// Click an image field: pick a picture for it.
pub fn choose_field_image(&mut self, name: &str) {
#[cfg(not(target_arch = "wasm32"))]
+3
View File
@@ -836,6 +836,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
close = true;
return;
};
if app.print_draft.handling == crate::print_ui::Handling::Label {
app.read_label_crops(id);
}
let Some(doc) = app.session.get(id) else { return };
let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
+159 -70
View File
@@ -15,11 +15,17 @@ use crate::canvas::{DocView, PageXform};
const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
#[derive(Clone, Copy, PartialEq)]
enum TextTarget {
Block(usize),
Added(usize),
}
/// A cached, read-only page background. The source glyphs are removed before rasterization;
/// colour, pictures and unselected text beneath the editor remain visible.
#[derive(Default)]
pub(crate) struct TextBackground {
key: Option<(u64, usize, usize)>,
key: Option<(u64, usize, TextTarget)>,
renderer: Option<pdfcraft_render::RenderPool>,
texture: Option<egui::TextureHandle>,
tag: Option<u64>,
@@ -33,12 +39,26 @@ impl TextBackground {
self.renderer.is_some() && self.received != self.tag
}
pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, editor: Option<&LineEditor>, scale: f32) {
let key = editor.map(|ed| (doc.edit_generation(), ed.page, ed.block));
pub fn prepare(
&mut self,
ctx: &egui::Context,
doc: &pdfcraft_engine::Document,
editor: Option<&LineEditor>,
draft: Option<&crate::content_ui::TextDraft>,
scale: f32,
) {
let key = editor
.map(|ed| (doc.edit_generation(), ed.page, TextTarget::Block(ed.block)))
.or_else(|| draft.and_then(|d| d.index.map(|index| (doc.edit_generation(), d.page, TextTarget::Added(index)))));
if self.key != key {
*self = Self { key, ..Default::default() };
if let Some((_, page, block)) = key {
self.renderer = doc.text_edit_preview(page, block).ok().map(|p| p.renderer());
if let Some((_, page, target)) = key {
self.renderer = match target {
TextTarget::Block(block) => doc.text_edit_preview(page, block),
TextTarget::Added(index) => doc.added_text_preview(page, index),
}
.ok()
.map(|p| p.renderer());
}
}
if let Some(ed) = editor {
@@ -82,11 +102,26 @@ impl TextBackground {
}
pub fn ready(&self, page: usize, block: usize) -> bool {
self.key.is_some_and(|(_, p, b)| (p, b) == (page, block)) && self.texture.is_some()
self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))) && self.texture.is_some()
}
pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, editor: Option<&LineEditor>) {
let Some(_) = editor.filter(|ed| ed.page == page && self.ready(page, ed.block)) else { return };
pub fn ready_added(&self, page: usize, index: usize) -> bool {
self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Added(index))) && self.texture.is_some()
}
pub fn paint(
&self,
painter: &egui::Painter,
xf: &PageXform,
page: usize,
editor: Option<&LineEditor>,
draft: Option<&crate::content_ui::TextDraft>,
) {
let ready = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block))
|| draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index)));
if !ready {
return;
}
let Some(tex) = &self.texture else { return };
// Font metrics can underestimate the actual glyph ink. Paint the complete textless
// page so overhanging glyphs cannot remain outside the estimated source rectangle.
@@ -273,7 +308,7 @@ fn look_of(b: &pdfcraft_engine::TextBlock) -> pdfcraft_engine::AddedText {
source_look(&b.base_font, b.size, b.color, b.bold, b.italic)
}
fn editor_font(look: &pdfcraft_engine::AddedText, size: f32) -> FontId {
pub(crate) fn editor_font(look: &pdfcraft_engine::AddedText, size: f32) -> FontId {
let family = match (look.family, look.bold) {
(pdfcraft_engine::FontFamily::Courier, _) => FontFamily::Monospace,
(_, true) => FontFamily::Name("semibold".into()),
@@ -575,6 +610,14 @@ pub(crate) enum ImageAction {
Save(usize, usize),
Copy(usize, usize),
CopyFile(usize, usize),
/// Copy the picture, then remove it from the page.
Cut(usize, usize),
/// Mark the picture for redaction (applied later from Redact a PDF).
Redact(usize, usize),
/// Create a link over the picture.
Link(usize, usize),
/// Open Recognize Text for the picture's page.
Recognize(usize),
}
fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] {
@@ -759,6 +802,22 @@ pub(crate) fn image_input(
if !allow_interaction {
return false;
}
// An open menu stays drawn wherever the pointer is: most of its items lie outside the picture
// (a logo is smaller than its menu), and a menu that is not drawn for a frame cannot be clicked.
// Only a menu opened by a right-click on the picture: one opened over text or empty page while
// a picture stays selected is the page's own menu.
let menu_key = resp.id.with("image-menu");
if resp.secondary_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(menu_key, false));
}
let mut menu_drawn = false;
if let Some(sel) = selected
&& resp.context_menu_opened()
&& ui.ctx().data(|d| d.get_temp::<bool>(menu_key)).unwrap_or(false)
{
resp.context_menu(|ui| image_menu(ui, page, sel, images, view, action));
menu_drawn = true;
}
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { return false };
if selected != Some(hit) {
@@ -775,65 +834,95 @@ pub(crate) fn image_input(
if resp.clicked() || resp.secondary_clicked() {
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None });
}
if selected == Some(hit) {
resp.context_menu(|ui| {
use pdfcraft_engine::ImageEdit as E;
let items: [(&str, Option<E>); 4] = [
(tl!("Rotate Clockwise"), Some(E::Rotate(1))),
(tl!("Rotate Counterclockwise"), Some(E::Rotate(3))),
(tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })),
(tl!("Flip Vertical"), Some(E::Flip { horizontal: false })),
];
for (label, change) in items {
if ui.button(label).clicked() {
view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c });
ui.close();
}
}
// Crop: the handles trim instead of resizing until Done.
if ui.button(tl!("Crop Image")).clicked() {
if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) {
s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect)));
}
ui.close();
}
let cropped =
images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01)));
if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() {
if let Some(im) = images.get(hit) {
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) });
}
ui.close();
}
let raster = images.get(hit).is_some_and(|image| !image.is_form);
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
*action = Some(ImageAction::Replace(page, hit));
ui.close();
}
if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() {
*action = Some(ImageAction::Save(page, hit));
ui.close();
}
// Copying also finds the picture inside grouped Form artwork.
if ui.button(tl!("Copy Image")).clicked() {
*action = Some(ImageAction::Copy(page, hit));
ui.close();
}
if ui.button(tl!("Copy Image in Original Format")).clicked() {
*action = Some(ImageAction::CopyFile(page, hit));
ui.close();
}
ui.separator();
if ui.button(tl!("Delete")).clicked() {
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete });
ui.close();
}
});
if resp.secondary_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
}
if selected == Some(hit) && !menu_drawn {
resp.context_menu(|ui| image_menu(ui, page, hit, images, view, action));
}
true
}
/// The right-click menu of the picture `hit` (its rotate, flip, replace, copy, cut, redact, link,
/// recognize and delete items).
fn image_menu(
ui: &mut egui::Ui,
page: usize,
hit: usize,
images: &[pdfcraft_engine::PageImage],
view: &mut DocView,
action: &mut Option<ImageAction>,
) {
use pdfcraft_engine::ImageEdit as E;
let items: [(&str, Option<E>); 4] = [
(tl!("Rotate Clockwise"), Some(E::Rotate(1))),
(tl!("Rotate Counterclockwise"), Some(E::Rotate(3))),
(tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })),
(tl!("Flip Vertical"), Some(E::Flip { horizontal: false })),
];
for (label, change) in items {
if ui.button(label).clicked() {
view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c });
ui.close();
}
}
// Crop: the handles trim instead of resizing until Done.
if ui.button(tl!("Crop Image")).clicked() {
if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) {
s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect)));
}
ui.close();
}
let cropped = images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01)));
if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() {
if let Some(im) = images.get(hit) {
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) });
}
ui.close();
}
let raster = images.get(hit).is_some_and(|image| !image.is_form);
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
*action = Some(ImageAction::Replace(page, hit));
ui.close();
}
if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() {
*action = Some(ImageAction::Save(page, hit));
ui.close();
}
// Cutting and copying also find the picture inside grouped Form artwork.
if ui.button(tl!("Cut Image")).clicked() {
*action = Some(ImageAction::Cut(page, hit));
ui.close();
}
if ui.button(tl!("Copy Image")).clicked() {
*action = Some(ImageAction::Copy(page, hit));
ui.close();
}
if ui.button(tl!("Copy Image in Original Format")).clicked() {
*action = Some(ImageAction::CopyFile(page, hit));
ui.close();
}
ui.separator();
if ui.button(tl!("Redact Image")).clicked() {
*action = Some(ImageAction::Redact(page, hit));
ui.close();
}
if ui.button(tl!("Create Link…")).clicked() {
*action = Some(ImageAction::Link(page, hit));
ui.close();
}
if ui.button(tl!("Recognize Text…")).clicked() {
*action = Some(ImageAction::Recognize(page));
ui.close();
}
ui.separator();
if ui.button(tl!("Delete")).clicked() {
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete });
ui.close();
}
}
/// Open `block` on `page` in the inline editor. The box's geometry and source are read from the
/// `lines` handed in, so callers pass the document's current inventory.
#[allow(clippy::too_many_arguments)]
@@ -1250,7 +1339,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
// extent: single rows grow toward the page edge, paragraphs rewrap at their
// source width, and the visible frame grows/shrinks vertically with their rows.
// An OCR phrase (several stretched words on one scanned line) also grows rightwards.
let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.lines.len() == 1 || block.invisible);
let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.rows.len() == 1 || block.invisible);
let width = if !ed.multiline && single_run && !ed.text.contains('\n') && ed.has_changes() {
let job = editor_job(ui, ed, &ed.text, scale, f32::INFINITY);
let natural = ui.fonts_mut(|f| f.layout_job(job)).size().x;
@@ -1395,7 +1484,7 @@ mod tests {
let ctx = egui::Context::default();
let mut background = TextBackground::default();
for _ in 0..100 {
background.prepare(&ctx, doc, Some(&ed), 1.0);
background.prepare(&ctx, doc, Some(&ed), None, 1.0);
if background.ready(0, 0) {
break;
}
@@ -1404,12 +1493,12 @@ mod tests {
assert!(background.ready(0, 0));
assert!(!background.pending());
let texture = background.texture.as_ref().unwrap().id();
background.prepare(&ctx, doc, Some(&ed), 1.0);
background.prepare(&ctx, doc, Some(&ed), None, 1.0);
assert_eq!(background.texture.as_ref().unwrap().id(), texture);
assert_eq!(doc.bytes, source);
assert_eq!(doc.edit_generation(), generation);
assert!(!doc.dirty && doc.can_undo().is_none());
background.prepare(&ctx, doc, None, 1.0);
background.prepare(&ctx, doc, None, None, 1.0);
assert!(background.texture.is_none() && background.renderer.is_none());
}
@@ -1424,7 +1513,7 @@ mod tests {
let ctx = egui::Context::default();
ctx.begin_pass(egui::RawInput { max_texture_side: Some(16384), ..Default::default() });
let mut background = TextBackground::default();
background.prepare(&ctx, doc, Some(&ed), 8.0);
background.prepare(&ctx, doc, Some(&ed), None, 8.0);
let expected = 8.0f32.min(crate::canvas::TILE_THRESHOLD / page.width.max(page.height));
assert!(expected > crate::canvas::BASE_SIDE / page.width.max(page.height), "the test page needs the larger side");
assert_eq!(background.tag, Some((expected * 1000.0) as u64));
@@ -1432,7 +1521,7 @@ mod tests {
ctx.end_pass().textures_delta.clear();
ctx.begin_pass(egui::RawInput { max_texture_side: Some(2048), ..Default::default() });
let mut small = TextBackground::default();
small.prepare(&ctx, doc, Some(&ed), 8.0);
small.prepare(&ctx, doc, Some(&ed), None, 8.0);
assert_eq!(small.tag, Some(((2048.0 / page.width.max(page.height)) * 1000.0) as u64));
ctx.end_pass().textures_delta.clear();
}
+112 -31
View File
@@ -1,18 +1,19 @@
//! Filling in forms on the page (execution plan M6; Acrobat fills fields in every viewing mode).
//!
//! Text fields open an in-place editor over the widget (Enter or clicking away commits, Escape
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. Combo
//! and list boxes open a list of their options. Every change is one undoable engine edit.
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. List
//! boxes are directly selectable in-place on the page (clicking picks an option; Ctrl-click
//! toggles on multi-select lists). Combo boxes open a list of their options. Every change is one
//! undoable engine edit.
use std::collections::HashSet;
use egui::{Color32, CornerRadius, Rect, Stroke, vec2};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
use crate::widgets;
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// Background of the in-place text field editor, the same in every theme.
@@ -181,12 +182,48 @@ pub(crate) fn page_input(
x.request_focus = true;
}
}
FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => {
FormFieldKind::Text | FormFieldKind::Combo => {
if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
}
}
FormFieldKind::List => {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
let r = widget_rect(xf, info, page, w.rect);
let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) };
let zoom = (r.height() as f64) / height_pt;
let y_pt = (p.y - r.min.y) as f64 / zoom;
let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 });
let picked_indices: Vec<usize> = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect();
let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len());
if let Some(opt_idx) = rows.option_at(top, y_pt).filter(|&i| i < f.options.len()) {
let export = &f.options[opt_idx].0;
let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
let shift = ui.input(|i| i.modifiers.shift);
let multi = f.has(field_flags::MULTI_SELECT);
let new_value = if multi && ctrl {
let mut picked = f.value.clone();
if let Some(pos) = picked.iter().position(|x| x == export) {
picked.remove(pos);
} else {
picked.push(export.clone());
}
FieldValue::Choice(picked)
} else if multi && shift && !f.value.is_empty() {
let anchor = f.value.last().and_then(|last| f.options.iter().position(|(e, _)| e == last)).unwrap_or(opt_idx);
let (min, max) = (anchor.min(opt_idx), anchor.max(opt_idx));
let picked = f.options[min..=max].iter().map(|(e, _)| e.clone()).collect();
FieldValue::Choice(picked)
} else {
FieldValue::Choice(vec![export.clone()])
};
if new_value != FieldValue::Choice(f.value.clone()) {
view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: new_value });
}
}
}
}
FieldPointer::Owned
}
@@ -196,7 +233,6 @@ pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option<Edit> {
let f = form.iter().find(|f| f.name == focus.name)?;
let value = match f.kind {
FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()),
FormFieldKind::List if f.has(field_flags::MULTI_SELECT) && focus.picked != f.value => FieldValue::Choice(focus.picked.clone()),
_ => return None,
};
Some(Edit::SetFieldValue { name: f.name.clone(), value })
@@ -309,13 +345,11 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi);
if focused {
painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside);
} else {
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
}
if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
} else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
}
@@ -352,7 +386,7 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) {
match f.kind {
FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => {
FormFieldKind::Text | FormFieldKind::Combo => {
let text = f.value.first().map(String::as_str).unwrap_or("");
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
@@ -374,6 +408,38 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField,
p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK);
}
}
FormFieldKind::List => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw };
let zoom = (xf.rect.width() / unrotated_w.max(1.0)) as f64;
let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 });
let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) };
let picked_indices: Vec<usize> = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect();
let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len());
let font_size = (rows.size * zoom).clamp(6.0, 64.0) as f32;
let row_h = (rows.row * zoom) as f32;
let inset = (rows.inset * zoom) as f32;
let p = painter.with_clip_rect(r);
let mut y = r.min.y + inset;
for (export, display) in f.options.iter().skip(top) {
if y + row_h > r.max.y {
break;
}
let row_rect = Rect::from_min_size(egui::pos2(r.min.x + 1.0, y), egui::vec2(r.width() - 2.0, row_h));
if f.value.contains(export) {
p.rect_filled(row_rect, CornerRadius::ZERO, Color32::from_rgb(0x99, 0xC0, 0xDC));
}
p.text(
egui::pos2(r.min.x + 3.0, y + font_size * 0.15),
egui::Align2::LEFT_TOP,
display,
egui::FontId::proportional(font_size),
Color32::BLACK,
);
y += row_h;
}
}
FormFieldKind::CheckBox => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
@@ -502,8 +568,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
return view.pending_edit.take();
}
}
FormFieldKind::Combo | FormFieldKind::List => {
let multi = f.kind == FormFieldKind::List && f.has(field_flags::MULTI_SELECT);
FormFieldKind::Combo => {
let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0)))
.order(egui::Order::Foreground)
.fixed_pos(rect.left_bottom() + vec2(0.0, 2.0))
@@ -512,27 +577,14 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
ui.set_min_width(rect.width().max(160.0));
ui.label(egui::RichText::new(&f.name).small().color(t.text_faint));
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
let Some(fx) = view.forms.focus.as_mut() else { return };
for (export, display) in &f.options {
let on = if multi { fx.picked.contains(export) } else { f.value.first() == Some(export) };
if multi {
let mut c = on;
if ui.add(widgets::checkbox(&mut c, display)).changed() {
if c {
fx.picked.push(export.clone());
} else {
fx.picked.retain(|x| x != export);
}
}
} else if ui.selectable_label(on, display).clicked() {
let on = f.value.first() == Some(export);
if ui.selectable_label(on, display).clicked() {
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
close = true;
}
}
});
if multi && ui.button(tl!("Done")).clicked() {
commit(view, form);
}
});
});
let clicked_away = ctx.input(|i| i.pointer.any_click())
@@ -545,6 +597,35 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
next = Some(!shift);
} else if clicked_away {
commit(view, form);
close = true;
}
}
FormFieldKind::List => {
let (up, down, esc, tab, shift) = ctx.input_mut(|i| {
(
// Consumed, so the arrows move the selection without also scrolling the page.
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.key_pressed(egui::Key::Escape),
i.key_pressed(egui::Key::Tab),
i.modifiers.shift,
)
});
if esc {
close = true;
} else if tab {
next = Some(!shift);
} else if (up || down) && !f.options.is_empty() {
let current_idx = f.value.last().and_then(|v| f.options.iter().position(|(e, _)| e == v)).unwrap_or(0);
let next_idx = if up { current_idx.saturating_sub(1) } else { (current_idx + 1).min(f.options.len().saturating_sub(1)) };
if next_idx != current_idx && next_idx < f.options.len() {
let export = f.options[next_idx].0.clone();
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) });
}
}
let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
if clicked_away {
close = true;
}
}
_ => close = true,
+271
View File
@@ -34,6 +34,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. يتبع الوضع التلقائي حجم النص في النظام. تُعرض الصفحات دائمًا بالدقة الكاملة للشاشة.
Identity الهوية
Name on new comments الاسم على التعليقات الجديدة
Hide hover previews for highlights without comment text إخفاء معاينات التمرير للتظليلات التي لا تحتوي على نص تعليق
Open… فتح…
Open Recent فتح الملفات الأخيرة
Pin folder to Home… تثبيت مجلد في الصفحة الرئيسية…
@@ -2278,6 +2279,8 @@ key Space مسافة
Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها.
Expand all توسيع الكل
Collapse all طي الكل
Collapse all pages طي كل الصفحات
Expand all pages توسيع كل الصفحات
Remove page إزالة الصفحة
Remove {n} pages إزالة {n} صفحات
@@ -2377,6 +2380,10 @@ animation effects Off معطّلة
The linked PDF opened, but its named destination was not applied. فُتح ملف PDF المرتبط، لكن لم تُطبَّق وجهته المسماة.
The linked PDF opened, but its page destination was out of range. فُتح ملف PDF المرتبط، لكن وجهة الصفحة خارج النطاق.
Local cross-PDF links require the desktop app تتطلب الروابط المحلية بين ملفات PDF تطبيق سطح المكتب
Cut Image قص الصورة
Redact Image حجب الصورة
Create Link… إنشاء رابط…
Recognize Text… التعرف على النص…
Minimize تصغير
Maximize تكبير
Restore استعادة
@@ -2394,6 +2401,270 @@ animation effects Off معطّلة
This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي:
Move tag earlier نقل العلامة للأعلى
Move tag later نقل العلامة للأسفل
# Completion: the rows the newer catalogs (lt, cs, es …) grew — certificates,
# digital-signature verification detail, accessibility checks, calendar weekdays
# and the newer plural counts — same clean-room Arabic as the rest of the file.
on page {label} في الصفحة {label}
A font isn't embedded أحد الخطوط غير مدمج
A {} action isn't allowed إجراء {} غير مسموح
A {} annotation has additional actions للتعليق التوضيحي {} إجراءات إضافية
A {} annotation has no appearance stream للتعليق التوضيحي {} بلا مظهر متدفق
A {} annotation isn't set to print, or is hidden التعليق التوضيحي {} غير مضبوط للطباعة أو مخفي
Accessibility Report تقرير الوصول
Add the PDFs to combine أضف ملفات PDF للدمج
Added the first {n} PDFs in {folder} أضيفت أول {n} ملف PDF في {folder}
Always دائماً
Automatically تلقائياً
CRL Signing توقيع CRL
Certificate Signing توقيع الشهادات
Change Author تغيير المؤلف
Change Keywords تغيير الكلمات المفتاحية
Change Subject تغيير الموضوع
Change Title تغيير العنوان
Choose a digital ID. اختر معرّفاً رقمياً.
Choose a file… اختيار ملف…
Choose a folder… اختيار مجلد…
Choose… اختيار…
Color اللون
Comment تعليق
Comment and review التعليق والمراجعة
Copied to clipboard نُسخ إلى الحافظة
Copy to clipboard نسخ إلى الحافظة
Create folder إنشاء مجلد
Create links إنشاء ارتباطات
Creator المنشئ
Cut and stack needs Two-sided: Off. Print single-sided sheets. يحتاج القص والتكديس إلى «على الوجهين: إيقاف». اطبع أوراقاً أحادية الوجه.
Data Encipherment تشفير البيانات
Decipher Only فك التشفير فقط
Detailed Report تقرير مفصل
DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile يُستخدم DeviceCMYK، لكن غرض إخراج PDF/A ليس ملف CMYK
DeviceRGB is used, but the PDF/A output intent isn't an RGB profile يُستخدم DeviceRGB، لكن غرض إخراج PDF/A ليس ملف RGB
Digital Signature توقيع رقمي
Disabled معطّل
Document properties (⌘D) خصائص المستند (⌘D)
Drop PDFs or folders here, or choose them. أفلت ملفات PDF أو المجلدات هنا، أو اخترها.
Duplicate تكرار
Enabled ممكّن
Encipher Only التشفير فقط
English إنجليزي
Enter a URL أدخل URL
Enter a name. أدخل اسماً.
Enter a page number أدخل رقم صفحة
Enter the field names أدخل أسماء الحقول
Error خطأ
Every PDF in the folder كل ملف PDF في المجلد
Every PDF in the folder and the folders inside it كل ملف PDF في المجلد والمجلدات داخله
Field "{}" on page {p} has no description (tooltip) الحقل "{}" في الصفحة {p} بلا وصف (تلميح)
Filename اسم الملف
Flatten Fill & Sign تسطيح التعبئة والتوقيع
Folder مجلد
Font الخط
Font (unnamed) on page {p} doesn't map its characters to Unicode الخط (غير المسمى) في الصفحة {p} لا يربط محارفه بيونيكود
Font {} on page {p} doesn't map its characters to Unicode الخط {} في الصفحة {p} لا يربط محارفه بيونيكود
Form field on page {p} isn't tagged حقل النموذج في الصفحة {p} غير موسوم
Help… تعليمات…
H{n} follows an H{d}; headings should not skip levels يتبع H{n} عنوان H{d}؛ لا ينبغي للعناوين تخطي المستويات
Import استيراد
Information معلومات
Inspector المفتش
Key Agreement اتفاقية المفاتيح
Key Encipherment تشفير المفاتيح
Loading… يُحمَّل…
Move نقل
Never أبداً
New bookmark (⌘B) إشارة مرجعية جديدة (⌘B)
Next التالي
Next (⌘G) التالي (⌘G)
No PDFs found in {folder} لم يُعثر على ملفات PDF في {folder}
No document language is set لم تُحدد لغة المستند
No page has text: the document appears to be images of pages لا صفحة تحوي نصاً: يبدو المستند صوراً للصفحات
No results لا نتائج
Non-Repudiation عدم الجحود
On تشغيل
Only the document's owner can change its security (open it with the permissions password) مالك المستند وحده يستطيع تغيير أمانه (افتحه بكلمة مرور الأذونات)
Open a PDF (⌘O) فتح PDF‏ (⌘O)
Open folder فتح المجلد
Organize تنظيم
Page {n} صفحة {n}
Page {n} of {total} صفحة {n} من {total}
Page {p} doesn't tab in structure order الصفحة {p} لا تترتب بترتيب البنية
Page {p}: {n} untagged content item الصفحة {p}: {n} عنصر محتوى غير موسوم
Page {p}: {n} untagged content items الصفحة {p}: {n} عناصر محتوى غير موسومة
Page {} has additional actions للصفحة {} إجراءات إضافية
Panel layout could not be restored: {error} تعذرت استعادة تخطيط اللوحات: {error}
Panel move was not applied: {error} لم يُطبَّق نقل اللوحة: {error}
Paste لصق
PdfCraft can't check this signature yet: {}. لا يستطيع PdfCraft التحقق من هذا التوقيع بعد: {}.
Previous السابق
Previous (⇧⌘G) السابق (⇧⌘G)
Print (⌘P) طباعة (⌘P)
Producer المنتج
Refresh تحديث
Rendering… يُعرض…
Report created أُنشئ التقرير
Rev. {rev}: Certified by an unknown signer المراجعة {rev}: معتمد من موقّع مجهول
Rev. {rev}: Certified by {name} المراجعة {rev}: معتمد من {name}
Rev. {rev}: Signed by an unknown signer المراجعة {rev}: موقع من موقع مجهول
Rev. {rev}: Signed by {name} المراجعة {rev}: موقع من {name}
Rotate view clockwise (⇧⌘+) تدوير العرض مع عقارب الساعة (⇧⌘+)
Rule Name اسم القاعدة
Save (M4) حفظ (M4)
Save as {level} حفظ كـ {level}
Save changes? حفظ التغييرات؟
Save file حفظ الملف
Search results نتائج البحث
Select all تحديد الكل
Signature is invalid التوقيع غير صالح
Signature is valid التوقيع صالح
Signature validity is unknown صلاحية التوقيع مجهولة
Signing time is from the clock on the signer's computer. وقت التوقيع من ساعة حاسوب الموقّع.
Still rendering — this page is unusually complex… لا يزال العرض جارياً — هذه الصفحة معقدة على غير العادة…
Success نجاح
System النظام
The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. توقيع ECDSA ليس بصيغة DER القياسية (بايتات زائدة أو حشو أو بايت إشارة ناقص)؛ قُرئ مع ذلك.
The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. توقيع ECDSA هو قيمتا r وs الخام بدلاً من متتابعة DER؛ قُرئ مع ذلك.
The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. قيمة توقيع RSA ليست بطول معامل المفتاح (بايتات صفرية بادئة حُذفت أو أُضيفت)؛ قُرئ مع ذلك.
The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. لا يحوي DigestInfo لتوقيع RSA معلمة NULL (كذلك تكتبها الموقّعات القديمة)؛ قُرئ مع ذلك.
The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. شهادة {} لا تسمح بهذا العدد من شهادات CA تحتها (طول المسار)، لذا لا تُستخدم للضمان عن {}.
The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. شهادة {} ليست شهادة CA يجوز لها إصدار الشهادات، لذا لا تُستخدم للضمان عن {}.
The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. شهادة {} لم تكن صالحة وقت التوقيع، لذا لا تُستخدم للضمان عن {}.
The checker found problems which may prevent the document from being fully accessible. وجد الفاحص مشكلات قد تمنع وصولاً كاملاً إلى المستند.
The document has been altered or corrupted since the signature was applied. عُدّل المستند أو تلف منذ تطبيق التوقيع.
The document has been altered or corrupted since the timestamp was applied. عُدّل المستند أو تلف منذ تطبيق الطابع الزمني.
The document has been altered since this signature was applied in ways it does not permit ({}). عُدّل المستند منذ تطبيق هذا التوقيع بطرق لا يسمح بها ({}).
The document has been altered since this timestamp was applied in ways it does not permit ({}). عُدّل المستند منذ تطبيق هذا الطابع الزمني بطرق لا يسمح بها ({}).
The document has been modified since this signature was applied, but the changes are permitted ({}). عُدّل المستند منذ تطبيق هذا التوقيع، لكن التغييرات مسموحة ({}).
The document has been modified since this timestamp was applied, but the changes are permitted ({}). عُدّل المستند منذ تطبيق هذا الطابع الزمني، لكن التغييرات مسموحة ({}).
The document has no tags (structure tree) لا وسوم في المستند (شجرة البنية)
The document has no title لا عنوان للمستند
The document isn't marked as tagged (MarkInfo Marked) المستند غير معلّم كموسوم (MarkInfo Marked)
The document isn't tagged, so none of its content is المستند غير موسوم، لذا لا شيء من محتواه موسوم
The document's embedded revocation information shows that the signer's certificate has not been revoked. تُظهر معلومات الإلغاء المدمجة في المستند أن شهادة الموقّع لم تُلغَ.
The embedded timestamp token is valid and its authority is trusted; trusted time is {}. رمز الطابع الزمني المدمج صالح وسلطته موثوقة؛ الوقت الموثوق هو {}.
The font {} isn't embedded الخط {} غير مدمج
The metadata doesn't identify the file as {} البيانات الوصفية لا تحدد الملف كـ {}
The password is incorrect. كلمة المرور غير صحيحة.
The password must have at least 6 characters. يجب أن تحوي كلمة المرور 6 محارف على الأقل.
The passwords do not match. كلمتا المرور غير متطابقتين.
The security settings don't allow screen readers to read the text إعدادات الأمان لا تسمح لقارئات الشاشة بقراءة النص
The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. خوارزمية التوقيع وخوارزمية الهضم في SignerInfo تسمّيان هضمين مختلفين؛ صُنع التوقيع بخوارزمية SignerInfo.
The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. يحمل التوقيع طابعاً زمنياً غير موثق ({}): سلطة الطابع الزمني ليست في قائمة شهاداتك الموثوقة، لذا يُستخدم وقت التوقيع الذي زعمه الموقّع بدلاً منه.
The signature contents are not hexadecimal. محتويات التوقيع ليست سداسية عشرية.
The signature could not be checked ({}). تعذر التحقق من التوقيع ({}).
The signature could not be read ({}). تعذرت قراءة التوقيع ({}).
The signature could not be verified ({}). تعذر التثبت من التوقيع ({}).
The signature has no valid byte range. لا نطاق بايتات صالحاً للتوقيع.
The signature includes an embedded timestamp. يتضمن التوقيع طابعاً زمنياً مدمجاً.
The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. التوقيع مرمّز BER (أطوال غير محددة) لا DER؛ قُرئ بتساهل.
The signature uses the legacy adbe.x509.rsa_sha1 format. يستخدم التوقيع تنسيق adbe.x509.rsa_sha1 القديم.
The signature value does not match the signer's certificate: the signature is corrupt. قيمة التوقيع لا تطابق شهادة الموقّع: التوقيع تالف.
The signature's byte range does not exclude exactly its contents. نطاق بايتات التوقيع لا يستبعد محتوياته بالضبط.
The signature's byte range does not match the file. نطاق بايتات التوقيع لا يطابق الملف.
The signature's timestamp token could not be verified ({}). تعذر تثبت رمز الطابع الزمني للتوقيع ({}).
The signature's timestamp token does not cover the signature value. رمز الطابع الزمني للتوقيع لا يغطي قيمة التوقيع.
The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. المحتوى الموقّع OCTET STRING مركّب (مقسم) كما يسمح BER؛ قُرئ مع ذلك.
The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). شهادة الموقّع لا تسمح بالتواقيع الرقمية (استخدام مفتاحها لا يضم توقيعاً رقمياً ولا عدم جحود).
The signer's certificate has been revoked ({}). أُلغيت شهادة الموقّع ({}).
The signer's certificate has critical extensions PdfCraft does not recognize ({}). لشهادة الموقّع امتدادات حرجة لا يتعرّف عليها PdfCraft ({}).
The signer's certificate is not in the signature. شهادة الموقّع ليست في التوقيع.
The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). شهادة الموقّع لم تُصدر لتوقيع المستندات (استخدامها الممتد للمفتاح لا يضم غرض توقيع مستندات).
The signer's certificate was not valid at the time of signing. شهادة الموقّع لم تكن صالحة وقت التوقيع.
The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. هوية الموقّع مجهولة لأنها ليست في قائمة شهاداتك الموثوقة ولا أي من شهاداتها الأم موثوقة.
The signer's identity is valid. هوية الموقّع صحيحة.
The timestamp authority's certificate was not valid at the time of timestamping. شهادة سلطة الطابع الزمني لم تكن صالحة وقت التوقيت.
The timestamp contents are not hexadecimal. محتويات الطابع الزمني ليست سداسية عشرية.
The timestamp has no valid byte range. لا نطاق بايتات صالحاً للطابع الزمني.
The timestamp token could not be read ({}). تعذرت قراءة رمز الطابع الزمني ({}).
The timestamp token is valid and its authority is trusted. رمز الطابع الزمني صالح وسلطته موثوقة.
The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. رمز الطابع الزمني صالح، لكن سلطة الطابع الزمني ليست في قائمة شهاداتك الموثوقة.
The timestamp's byte range does not exclude exactly its contents. نطاق بايتات الطابع الزمني لا يستبعد محتوياته بالضبط.
The timestamp's byte range does not match the file. نطاق بايتات الطابع الزمني لا يطابق الملف.
The window shows the file name, not the title (Initial View > Show) تُظهر النافذة اسم الملف لا العنوان (العرض الأولي > إظهار)
Theme السمة
There is no folder to save the digital ID in. لا مجلد لحفظ المعرّف الرقمي فيه.
This document has not been modified since this signature was applied. لم يُعدَّل المستند منذ تطبيق هذا التوقيع.
This document has not been modified since this timestamp was applied. لم يُعدَّل المستند منذ تطبيق هذا الطابع الزمني.
This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. يستخدم هذا التوقيع RIPEMD-160، خوارزمية هضم قديمة بطول 160 بت لم تعد موصى بها للتواقيع. لا يزال يصح، لكن لا ينبغي الاعتماد عليه.
This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. يستخدم هذا التوقيع SHA-1، خوارزمية هضم ضعيفة: أُثبتت تصادمات فيها. لا يزال يصح لأن مستندات كثيرة وقّعت قبل سنوات تستخدمه، لكن لا ينبغي الاعتماد عليه.
Tools الأدوات
Unsigned signature field حقل توقيع غير موقع
Unsigned signature field: {name} حقل توقيع غير موقع: {name}
Warning تحذير
Zoom in (⌘+) تكبير (⌘+)
Zoom in / out (also pinch or ⌘-scroll) تكبير / تصغير (أيضاً قرص أو ⌘-تمرير)
Zoom out (⌘−) تصغير (⌘−)
alternate text hides an annotation inside it النص البديل يخفي تعليقاً توضيحياً داخله
alternate text on an element with no page content نص بديل على عنصر بلا محتوى صفحة
comments تعليقات
document security store مخزن أمان المستند
document structure بنية المستند
form fill تعبئة نموذج
its alternate text is inside another element's and will never be read نصه البديل داخل نص عنصر آخر ولن يُقرأ أبداً
links ارتباطات
metadata بيانات وصفية
no alternate text لا نص بديل
no header cells (TH) لا خلايا رأس (TH)
no summary لا ملخصاً
not in a TR ليس في TR
not in a Table, THead, TBody or TFoot ليس في Table أو THead أو TBody أو TFoot
not in an L (list) ليس في L (قائمة)
not in an LI (list item) ليس في LI (عنصر قائمة)
of من
other changes تغييرات أخرى
page content محتوى الصفحة
page {n} is out of range (1–{count}) الصفحة {n} خارج النطاق (1–{count})
pages added or removed صفحات أُضيفت أو حُذفت
rows have between {min} and {max} columns للصفوف بين {min} و{max} عموداً
the first heading is an H{n}; headings should not skip levels العنوان الأول H{n}؛ لا ينبغي للعناوين تخطي المستويات
the signed version could not be read تعذرت قراءة النسخة الموقعة
{n} pages and no bookmarks {n} صفحات ولا إشارات مرجعية
{page} is not a page number or label {page} ليس رقم صفحة أو تسمية
{ty} element on page {p}: {what} عنصر {ty} في الصفحة {p}: {what}
{ty} element: {what} عنصر {ty}: {what}
{who}, certificate issued by an unknown issuer. {who}، شهادة صادرة من مُصدر مجهول.
{who}, certificate issued by {issuer}. {who}، شهادة صادرة من {issuer}.
{} annotation on page {p} isn't tagged التعليق التوضيحي {} في الصفحة {p} غير موسوم
{} annotations aren't allowed التعليقات التوضيحية {} غير مسموحة
… and {n} more … و{n} أخرى
@plural Delete {n} field|Delete {n} fields حذف {n} حقل|حذف {n} حقل|حذف {n} حقلين|حذف {n} حقول|حذف {n} حقلاً|حذف {n} حقل
@plural {n} byte|{n} bytes {n} بايت|{n} بايت|{n} بايتان|{n} بايتات|{n} بايتاً|{n} بايت
@plural {n} comment|{n} comments {n} تعليق|{n} تعليق|{n} تعليقان|{n} تعليقات|{n} تعليقاً|{n} تعليق
@plural {n} file|{n} files {n} ملف|{n} ملف|{n} ملفان|{n} ملفات|{n} ملفاً|{n} ملف
@plural {n} image optimized|{n} images optimized {n} صورة محسّنة|{n} صورة محسّنة|{n} صورتان محسّنتان|{n} صور محسّنة|{n} صورة محسّنة|{n} صورة محسّنة
@plural {n} item discarded|{n} items discarded {n} عنصر مهمل|{n} عنصر مهمل|{n} عنصران مهملان|{n} عناصر مهملة|{n} عنصراً مهملًا|{n} عنصر مهمل
@plural {n} pixel|{n} pixels {n} بكسل|{n} بكسل|{n} بكسلان|{n} بكسلات|{n} بكسلاً|{n} بكسل
@plural {n} result|{n} results {n} نتيجة|{n} نتيجة|{n} نتيجتان|{n} نتائج|{n} نتيجةً|{n} نتيجة
calendar weekday Fr جمعة
calendar weekday Mo إثنين
calendar weekday Sa سبت
calendar weekday Su أحد
calendar weekday Th خميس
calendar weekday Tu ثلاثاء
calendar weekday We أربعاء
certificate Basic constraints القيود الأساسية
certificate Intended usage الاستخدام المقصود
certificate Issued by صادر من
certificate Issued to مُصدر إلى
certificate Issuer المُصدر
certificate Key usage استخدام المفتاح
certificate Public key المفتاح العام
certificate SHA-1 digest بصمة SHA-1
certificate SHA-256 digest بصمة SHA-256
certificate Self-signed ذاتي التوقيع
certificate Serial number الرقم التسلسلي
certificate Valid from صالح من
certificate Valid to صالح إلى
certificate Validity ends تنتهي الصلاحية
certificate Validity starts تبدأ الصلاحية
certificate Version الإصدار
Turn upside down (180°) اقلب رأسًا على عقب (180°)
4 x 6 in label (101.6 x 152.4 mm) ملصق 4 × 6 بوصة (101.6 × 152.4 مم)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. يقص كل صفحة إلى المنطقة المطبوعة، ويديرها لتصبح مستقيمة ويلائمها مع الورقة دون تمديد. ملصق واحد لكل ورقة.
Find untagged content العثور على محتوى غير مُعلَّم
No untagged content was found. لم يتم العثور على محتوى غير معلَّم.
Found {n} untagged content problems. تم العثور على {n} مشكلة في محتوى غير معلَّم.
Tag form fields وضع علامات على حقول النموذج
Mark as artifact وضع علامة كعنصر زخرفي
Remove this leaf tag from reading order and mark its direct content as an artifact أزل هذه العلامة من ترتيب القراءة وضع علامة على محتواها المباشر كعنصر زخرفي
Mark content as artifact وضع علامة على المحتوى كعنصر زخرفي
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -60,6 +60,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Автоматично следва размера на текста в системата. Страниците винаги се изобразяват с пълната разделителна способност на екрана.
Identity Самоличност
Name on new comments Име в новите коментари
Hide hover previews for highlights without comment text Скриване на прегледите при посочване за маркирания без текст на коментар
Open… Отвори…
Open Recent Последно отваряни
Pin folder to Home… Закачи папка в Начало…
@@ -2218,6 +2219,8 @@ key Space Интервал
Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат.
Expand all Разгъни всички
Collapse all Свий всички
Collapse all pages Свий всички страници
Expand all pages Разгъни всички страници
Remove page Премахни страницата
Remove {n} pages Премахни {n} страници
@@ -2319,6 +2322,10 @@ animation effects Off Изключени
The linked PDF opened, but its named destination was not applied. Свързаният PDF се отвори, но именуваната му цел не беше приложена.
The linked PDF opened, but its page destination was out of range. Свързаният PDF се отвори, но целевата му страница е извън обхвата.
Local cross-PDF links require the desktop app Локалните връзки между PDF файлове изискват настолното приложение
Cut Image Изрежи изображението
Redact Image Заличи изображението
Create Link… Създай връзка…
Recognize Text… Разпознаване на текст…
Minimize Минимизиране
Maximize Максимизиране
Restore Възстановяване
@@ -2336,6 +2343,13 @@ animation effects Off Изключени
This document is trying to open a local file: Този документ се опитва да отвори локален файл:
Move tag earlier Преместване на етикета по-рано
Move tag later Преместване на етикета по-късно
Turn upside down (180°) Обърни наопаки (180°)
4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
Find untagged content Намиране на немаркирано съдържание
No untagged content was found. Не е намерено немаркирано съдържание.
Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание.
Tag form fields Маркиране на полетата на формуляра
Mark as artifact Маркиране като артефакт
Remove this leaf tag from reading order and mark its direct content as an artifact Премахнете този листов етикет от реда на четене и маркирайте прякото му съдържание като артефакт
Mark content as artifact Маркиране на съдържание като артефакт
Can't render this file because it contains an unexpected character in line 8 and column 16.
+14
View File
@@ -40,6 +40,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Automaticky se řídí velikostí textu v systému. Stránky se vždy vykreslují v plném rozlišení displeje.
Identity Identita
Name on new comments Jméno autora nových komentářů
Hide hover previews for highlights without comment text Skrýt náhledy při najetí u zvýraznění bez textu komentáře
Enable Acrobat JavaScript Povolit Acrobat JavaScript
OK OK
Open… Otevřít…
@@ -2256,6 +2257,8 @@ certificate Subject Subjekt
List view Zobrazení seznamu
Expand all Rozbalit vše
Collapse all Sbalit vše
Collapse all pages Sbalit všechny stránky
Expand all pages Rozbalit všechny stránky
Files are combined in the order shown. Drag them to change it. Soubory se slučují v zobrazeném pořadí. Pořadí změníte přetažením.
Pages: {k} of {n} Stránky: {k} z {n}
Check the pages Zkontrolujte stránky
@@ -2430,6 +2433,10 @@ animation effects Off Vypnuto
The linked PDF opened, but its named destination was not applied. Odkazovaný PDF se otevřel, ale jeho pojmenovaný cíl nebyl použit.
The linked PDF opened, but its page destination was out of range. Odkazovaný PDF se otevřel, ale cílová stránka je mimo rozsah.
Local cross-PDF links require the desktop app Místní odkazy mezi soubory PDF vyžadují desktopovou aplikaci
Cut Image Vyjmout obrázek
Redact Image Začernit obrázek
Create Link… Vytvořit odkaz…
Recognize Text… Rozpoznat text…
Minimize Minimalizovat
Maximize Maximalizovat
Restore Obnovit
@@ -2447,6 +2454,13 @@ animation effects Off Vypnuto
This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor:
Move tag earlier Přesunout značku dříve
Move tag later Přesunout značku později
Turn upside down (180°) Otočit vzhůru nohama (180°)
4 x 6 in label (101.6 x 152.4 mm) Štítek 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ořízne každou stránku na potištěnou oblast, otočí ji svisle a přizpůsobí listu bez deformace. Jeden štítek na list.
Find untagged content Najít neoznačený obsah
No untagged content was found. Nebyl nalezen žádný neoznačený obsah.
Found {n} untagged content problems. Nalezeno problémů s neoznačeným obsahem: {n}.
Tag form fields Označit pole formuláře
Mark as artifact Označit jako artefakt
Remove this leaf tag from reading order and mark its direct content as an artifact Odeberte tuto značku listu z pořadí čtení a označte její přímý obsah jako artefakt
Mark content as artifact Označit obsah jako artefakt
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -23,6 +23,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Automatisch folgt der Textgröße des Systems. Seiten werden immer in der vollen Auflösung des Bildschirms dargestellt.
Identity Identität
Name on new comments Name für neue Kommentare
Hide hover previews for highlights without comment text Hover-Vorschau für Hervorhebungen ohne Kommentartext ausblenden
Open… Öffnen…
New blank PDF Neues leeres PDF
Create PDF from file… PDF aus Datei erstellen…
@@ -2260,6 +2261,8 @@ key Space Leertaste
Switch to List view to change the pages it takes. Wechsle zur Listenansicht, um die übernommenen Seiten zu ändern.
Expand all Alle ausklappen
Collapse all Alle einklappen
Collapse all pages Alle Seiten einklappen
Expand all pages Alle Seiten ausklappen
Remove page Seite entfernen
Remove {n} pages {n} Seiten entfernen
@@ -2357,6 +2360,10 @@ animation effects Off Aus
The linked PDF opened, but its named destination was not applied. Die verknüpfte PDF wurde geöffnet, aber ihr benanntes Ziel wurde nicht angewendet.
The linked PDF opened, but its page destination was out of range. Die verknüpfte PDF wurde geöffnet, aber ihr Seitenziel liegt außerhalb des Bereichs.
Local cross-PDF links require the desktop app Lokale Verknüpfungen zwischen PDFs erfordern die Desktop-App
Cut Image Bild ausschneiden
Redact Image Bild schwärzen
Create Link… Link erstellen…
Recognize Text… Text erkennen…
Minimize Minimieren
Maximize Maximieren
Restore Wiederherstellen
@@ -2374,6 +2381,13 @@ animation effects Off Aus
This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen:
Move tag earlier Tag früher verschieben
Move tag later Tag später verschieben
Turn upside down (180°) Auf den Kopf drehen (180°)
4 x 6 in label (101.6 x 152.4 mm) Etikett 4 × 6 Zoll (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Schneidet jede Seite auf ihren bedruckten Bereich zu, richtet sie auf und passt sie ohne Verzerrung an das Blatt an. Ein Etikett pro Blatt.
Find untagged content Nicht markierten Inhalt suchen
No untagged content was found. Es wurde kein unmarkierter Inhalt gefunden.
Found {n} untagged content problems. {n} Probleme mit unmarkiertem Inhalt gefunden.
Tag form fields Formularfelder taggen
Mark as artifact Als Artefakt markieren
Remove this leaf tag from reading order and mark its direct content as an artifact Dieses Blatt-Tag aus der Lesereihenfolge entfernen und seinen direkten Inhalt als Artefakt markieren
Mark content as artifact Inhalt als Artefakt markieren
Can't render this file because it contains an unexpected character in line 6 and column 51.
+14
View File
@@ -16,6 +16,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Η αυτόματη ρύθμιση ακολουθεί το μέγεθος κειμένου του συστήματος. Οι σελίδες αποδίδονται πάντα στην πλήρη ανάλυση της οθόνης.
Identity Ταυτότητα
Name on new comments Όνομα στα νέα σχόλια
Hide hover previews for highlights without comment text Απόκρυψη προεπισκοπήσεων κατά την αιώρηση για επισημάνσεις χωρίς κείμενο σχολίου
Open… Άνοιγμα…
Open Recent Άνοιγμα πρόσφατων
Clear Recent Files Εκκαθάριση πρόσφατων αρχείων
@@ -1618,6 +1619,8 @@
Switch to List view to change the pages it takes. Μεταβείτε στην προβολή λίστας για να αλλάξετε ποιες σελίδες θα συμπεριληφθούν.
Expand all Ανάπτυξη όλων
Collapse all Σύμπτυξη όλων
Collapse all pages Σύμπτυξη όλων των σελίδων
Expand all pages Ανάπτυξη όλων των σελίδων
Remove {n} pages Αφαίρεση {n} σελίδων
Add an image field Προσθήκη πεδίου εικόνας
Add a date field Προσθήκη πεδίου ημερομηνίας
@@ -2366,6 +2369,10 @@ animation effects Off Ανενεργά
Local cross-PDF links require the desktop app Οι τοπικοί σύνδεσμοι μεταξύ PDF απαιτούν την εφαρμογή υπολογιστή
The existing action will be preserved. Η υπάρχουσα ενέργεια θα διατηρηθεί.
This form's scripts are turned off because one ran too long. Reopen the document to run them again. Τα σενάρια αυτής της φόρμας είναι απενεργοποιημένα, επειδή ένα από αυτά εκτελούνταν για πολύ ώρα. Ανοίξτε ξανά το έγγραφο για να εκτελεστούν ξανά.
Cut Image Αποκοπή εικόνας
Redact Image Απόκρυψη ευαίσθητου περιεχομένου εικόνας
Create Link… Δημιουργία συνδέσμου…
Recognize Text… Αναγνώριση κειμένου…
Minimize Ελαχιστοποίηση
Maximize Μεγιστοποίηση
Restore Επαναφορά
@@ -2383,6 +2390,13 @@ animation effects Off Ανενεργά
This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο:
Move tag earlier Μετακίνηση ετικέτας νωρίτερα
Move tag later Μετακίνηση ετικέτας αργότερα
Turn upside down (180°) Αναποδογύρισμα (180°)
4 x 6 in label (101.6 x 152.4 mm) Ετικέτα 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Περικόπτει κάθε σελίδα στην τυπωμένη περιοχή της, την ισιώνει και την προσαρμόζει στο φύλλο χωρίς παραμόρφωση. Μία ετικέτα ανά φύλλο.
Find untagged content Εύρεση μη επισημασμένου περιεχομένου
No untagged content was found. Δεν βρέθηκε μη επισημασμένο περιεχόμενο.
Found {n} untagged content problems. Βρέθηκαν {n} προβλήματα μη επισημασμένου περιεχομένου.
Tag form fields Επισήμανση πεδίων φόρμας
Mark as artifact Σήμανση ως τεχνούργημα
Remove this leaf tag from reading order and mark its direct content as an artifact Αφαιρέστε αυτήν την ετικέτα φύλλου από τη σειρά ανάγνωσης και σημειώστε το άμεσο περιεχόμενό της ως τεχνούργημα
Mark content as artifact Σήμανση περιεχομένου ως τεχνούργημα
Can't render this file because it contains an unexpected character in line 476 and column 9.
+14
View File
@@ -23,6 +23,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Automático sigue el tamaño del texto del sistema. Las páginas siempre se muestran con la resolución completa de la pantalla.
Identity Identidad
Name on new comments Nombre en los comentarios nuevos
Hide hover previews for highlights without comment text Ocultar las vistas previas al pasar el puntero en resaltados sin texto de comentario
Open… Abrir…
Open Recent Abrir recientes
Clear Recent Files Borrar archivos recientes
@@ -2396,6 +2397,8 @@ key Space Espacio
Switch to List view to change the pages it takes. Cambia a la vista de lista para cambiar las páginas que se incluyen.
Expand all Expandir todo
Collapse all Contraer todo
Collapse all pages Contraer todas las páginas
Expand all pages Expandir todas las páginas
Remove page Quitar página
Remove {n} pages Quitar {n} páginas
@@ -2496,6 +2499,10 @@ animation effects Off Desactivados
The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango.
Local cross-PDF links require the desktop app Los enlaces locales entre PDF requieren la aplicación de escritorio
The existing action will be preserved. Se conservará la acción existente.
Cut Image Cortar imagen
Redact Image Censurar imagen
Create Link… Crear enlace…
Recognize Text… Reconocer texto…
Minimize Minimizar
Maximize Maximizar
Restore Restaurar
@@ -2513,6 +2520,13 @@ animation effects Off Desactivados
This document is trying to open a local file: Este documento intenta abrir un archivo local:
Move tag earlier Mover etiqueta antes
Move tag later Mover etiqueta después
Turn upside down (180°) Girar boca abajo (180°)
4 x 6 in label (101.6 x 152.4 mm) Etiqueta 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Recorta cada página a su área impresa, la endereza y la ajusta a la hoja sin deformarla. Una etiqueta por hoja.
Find untagged content Buscar contenido sin etiquetar
No untagged content was found. No se encontró contenido sin etiquetar.
Found {n} untagged content problems. Se encontraron {n} problemas de contenido sin etiquetar.
Tag form fields Etiquetar campos de formulario
Mark as artifact Marcar como artefacto
Remove this leaf tag from reading order and mark its direct content as an artifact Quite esta etiqueta hoja del orden de lectura y marque su contenido directo como artefacto
Mark content as artifact Marcar contenido como artefacto
Can't render this file because it contains an unexpected character in line 6 and column 60.
+14
View File
@@ -23,6 +23,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Automatique suit la taille du texte du système. Les pages sont toujours rendues à la pleine résolution de l'écran.
Identity Identité
Name on new comments Nom sur les nouveaux commentaires
Hide hover previews for highlights without comment text Masquer les aperçus au survol des surlignages sans texte de commentaire
Open… Ouvrir…
New blank PDF Nouveau PDF vierge
Create PDF from file… Créer un PDF à partir d'un fichier…
@@ -2259,6 +2260,8 @@ key Space Espace
Switch to List view to change the pages it takes. Passez à la vue en liste pour modifier les pages prises.
Expand all Tout développer
Collapse all Tout réduire
Collapse all pages Réduire toutes les pages
Expand all pages Développer toutes les pages
Remove page Retirer la page
Remove {n} pages Retirer {n} pages
@@ -2356,6 +2359,10 @@ animation effects Off Désactivés
The linked PDF opened, but its named destination was not applied. Le PDF lié s'est ouvert, mais sa destination nommée n'a pas été appliquée.
The linked PDF opened, but its page destination was out of range. Le PDF lié s'est ouvert, mais sa page de destination est hors limites.
Local cross-PDF links require the desktop app Les liens locaux entre PDF nécessitent l'application de bureau
Cut Image Couper l'image
Redact Image Biffer l'image
Create Link… Créer un lien…
Recognize Text… Reconnaître le texte…
Minimize Réduire
Maximize Agrandir
Restore Restaurer
@@ -2373,6 +2380,13 @@ animation effects Off Désactivés
This document is trying to open a local file: Ce document tente d’ouvrir un fichier local :
Move tag earlier Déplacer la balise plus tôt
Move tag later Déplacer la balise plus tard
Turn upside down (180°) Retourner (180°)
4 x 6 in label (101.6 x 152.4 mm) Étiquette 4 × 6 po (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Rogne chaque page à sa zone imprimée, la redresse et l’ajuste à la feuille sans la déformer. Une étiquette par feuille.
Find untagged content Rechercher le contenu non balisé
No untagged content was found. Aucun contenu non balisé n’a été trouvé.
Found {n} untagged content problems. {n} problèmes de contenu non balisé trouvés.
Tag form fields Baliser les champs de formulaire
Mark as artifact Marquer comme artefact
Remove this leaf tag from reading order and mark its direct content as an artifact Retirez cette balise feuille de l’ordre de lecture et marquez son contenu direct comme artefact
Mark content as artifact Marquer le contenu comme artefact
Can't render this file because it contains an unexpected character in line 6 and column 39.
+14
View File
@@ -23,6 +23,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Az Automatikus beállítás a rendszer szövegméretét követi. Az oldalak mindig a kijelző teljes felbontásában jelennek meg.
Identity Személyazonosság
Name on new comments Név az új megjegyzéseken
Hide hover previews for highlights without comment text Megjegyzésszöveg nélküli kiemelések előnézetének elrejtése rámutatáskor
Open… Megnyitás…
New blank PDF Új üres PDF
Create PDF from file… PDF létrehozása fájlból…
@@ -2254,6 +2255,8 @@ key Space Szóköz
Switch to List view to change the pages it takes. Válts listanézetre, ha módosítanád, mely oldalak kerüljenek bele.
Expand all Összes kibontása
Collapse all Összes összecsukása
Collapse all pages Összes oldal összecsukása
Expand all pages Összes oldal kibontása
Remove page Oldal eltávolítása
Remove {n} pages {n} oldal eltávolítása
@@ -2355,6 +2358,10 @@ animation effects Off Ki
The linked PDF opened, but its named destination was not applied. A hivatkozott PDF megnyílt, de a nevesített cél nem lett alkalmazva.
The linked PDF opened, but its page destination was out of range. A hivatkozott PDF megnyílt, de a céloldal a tartományon kívül esik.
Local cross-PDF links require the desktop app A PDF-ek közötti helyi hivatkozásokhoz az asztali alkalmazás szükséges
Cut Image Kép kivágása
Redact Image Kép kitakarása
Create Link… Hivatkozás létrehozása…
Recognize Text… Szövegfelismerés…
Minimize Minimalizálás
Maximize Maximalizálás
Restore Visszaállítás
@@ -2372,6 +2379,13 @@ animation effects Off Ki
This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni:
Move tag earlier Címke korábbra helyezése
Move tag later Címke későbbre helyezése
Turn upside down (180°) Fejjel lefelé fordítás (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 hüvelykes címke (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Minden oldalt a nyomtatott területére vág, függőlegesre fordítja, és torzítás nélkül a laphoz igazítja. Laponként egy címke.
Find untagged content Címkézetlen tartalom keresése
No untagged content was found. Nem található címkézetlen tartalom.
Found {n} untagged content problems. {n} címkézetlen tartalmi probléma található.
Tag form fields Űrlapmezők címkézése
Mark as artifact Megjelölés műtárgyként
Remove this leaf tag from reading order and mark its direct content as an artifact Távolítsa el ezt a levélcímkét az olvasási sorrendből, és jelölje meg a közvetlen tartalmát műtárgyként
Mark content as artifact Tartalom megjelölése műtárgyként
Can't render this file because it contains an unexpected character in line 7 and column 44.
+14
View File
@@ -29,6 +29,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Auto segue la dimensione del testo del sistema. Le pagine vengono sempre visualizzate alla piena risoluzione dello schermo.
Identity Identità
Name on new comments Nome sui nuovi commenti
Hide hover previews for highlights without comment text Nascondi le anteprime al passaggio del puntatore per le evidenziazioni senza testo di commento
Open… Apri…
Open Recent Apri recenti
Pin folder to Home… Fissa una cartella sulla Home…
@@ -2266,6 +2267,8 @@ key Space Spazio
Switch to List view to change the pages it takes. Passa alla vista elenco per cambiare le pagine incluse.
Expand all Espandi tutto
Collapse all Comprimi tutto
Collapse all pages Comprimi tutte le pagine
Expand all pages Espandi tutte le pagine
Remove page Rimuovi pagina
Remove {n} pages Rimuovi {n} pagine
@@ -2365,6 +2368,10 @@ animation effects Off Disattivati
The linked PDF opened, but its named destination was not applied. Il PDF collegato è stato aperto, ma la sua destinazione con nome non è stata applicata.
The linked PDF opened, but its page destination was out of range. Il PDF collegato è stato aperto, ma la pagina di destinazione è fuori intervallo.
Local cross-PDF links require the desktop app I collegamenti locali tra PDF richiedono l'app desktop
Cut Image Taglia immagine
Redact Image Oscura immagine
Create Link… Crea collegamento…
Recognize Text… Riconosci testo…
Minimize Riduci a icona
Maximize Ingrandisci
Restore Ripristina
@@ -2382,6 +2389,13 @@ animation effects Off Disattivati
This document is trying to open a local file: Questo documento sta tentando di aprire un file locale:
Move tag earlier Sposta tag prima
Move tag later Sposta tag dopo
Turn upside down (180°) Capovolgi (ruota di 180°)
4 x 6 in label (101.6 x 152.4 mm) Etichetta 4×6 - 101,6 × 152,4 mm
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ritaglia l'area stampata di ogni pagina, la raddrizza e la adatta al foglio senza deformarla. Un'etichetta per foglio.
Find untagged content Trova contenuto senza tag
No untagged content was found. Non è stato trovato contenuto senza tag.
Found {n} untagged content problems. Trovati {n} problemi di contenuto senza tag.
Tag form fields Assegna tag ai campi del modulo
Mark as artifact Segna come artefatto
Remove this leaf tag from reading order and mark its direct content as an artifact Rimuovi questo tag foglia dall'ordine di lettura e segna il suo contenuto diretto come artefatto
Mark content as artifact Segna il contenuto come artefatto
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -29,6 +29,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. 自動にすると、システムの文字サイズに合わせます。ページは常にディスプレイの最大解像度で描画されます。
Identity 個人情報
Name on new comments 新しい注釈の作成者名
Hide hover previews for highlights without comment text コメントテキストのないハイライトのホバープレビューを非表示にする
Open… 開く…
Open Recent 最近のファイルを開く
Clear Recent Files 最近のファイルをクリア
@@ -2255,6 +2256,8 @@ key Space スペース
Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。
Expand all すべて展開
Collapse all すべて折りたたむ
Collapse all pages すべてのページを折りたたむ
Expand all pages すべてのページを展開
Remove page ページを削除
Remove {n} pages {n} ページを削除
@@ -2356,6 +2359,10 @@ animation effects Off オフ
The linked PDF opened, but its named destination was not applied. リンク先の PDF を開きましたが、名前付き移動先は適用されませんでした。
The linked PDF opened, but its page destination was out of range. リンク先の PDF を開きましたが、移動先のページが範囲外です。
Local cross-PDF links require the desktop app PDF 間のローカルリンクにはデスクトップアプリが必要です
Cut Image 画像を切り取り
Redact Image 画像を墨消し
Create Link… リンクを作成…
Recognize Text… テキストを認識…
Minimize 最小化
Maximize 最大化
Restore 元のサイズに戻す
@@ -2373,6 +2380,13 @@ animation effects Off オフ
This document is trying to open a local file: この文書はローカルファイルを開こうとしています:
Move tag earlier タグを前へ移動
Move tag later タグを後へ移動
Turn upside down (180°) 上下反転 (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 インチ ラベル (101.6 × 152.4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 各ページを印刷領域で切り抜き、正しい向きに回転して、伸縮させずに用紙に合わせます。1 枚に 1 ラベル。
Find untagged content タグなしのコンテンツを検索
No untagged content was found. タグなしのコンテンツは見つかりませんでした。
Found {n} untagged content problems. タグなしのコンテンツの問題が {n} 件見つかりました。
Tag form fields フォームフィールドにタグを付ける
Mark as artifact アーティファクトとしてマーク
Remove this leaf tag from reading order and mark its direct content as an artifact このリーフタグを読み上げ順序から削除し、その直接のコンテンツをアーティファクトとしてマークします
Mark content as artifact コンテンツをアーティファクトとしてマーク
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -9,6 +9,7 @@
Interface language Интерфейс тілі
Identity Жеке деректер
Name on new comments Жаңа түсініктемелердегі аты
Hide hover previews for highlights without comment text Түсініктеме мәтіні жоқ белгілеулер үшін меңзер алдын ала қарауын жасыру
Open… Ашу…
Open Recent Соңғы файлдарды ашу
Pin folder to Home… Қалтаны Басты бетке бекіту…
@@ -2141,6 +2142,8 @@ calendar date December Желтоқсан
Check the pages Беттерді белгілеу
Closed arrow Жабық көрсеткі
Collapse all Барлығын жию
Collapse all pages Барлық беттерді жию
Expand all pages Барлық беттерді жаю
Column select Баған бойынша таңдау
Comb of characters Таңбалар торы
Custom font Арнаулы қаріп
@@ -2354,6 +2357,10 @@ animation effects Off Өшірулі
The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады.
The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс.
Local cross-PDF links require the desktop app PDF файлдары арасындағы жергілікті сілтемелер үшін жұмыс үстелі қолданбасы қажет
Cut Image Суретті кесу
Redact Image Суретті жасыру
Create Link… Сілтеме жасау…
Recognize Text… Мәтінді тану…
Minimize Жию
Maximize Жаю
Restore Қалпына келтіру
@@ -2371,6 +2378,13 @@ animation effects Off Өшірулі
This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр:
Move tag earlier Тегті ертерек жылжыту
Move tag later Тегті кейінірек жылжыту
Turn upside down (180°) Төңкеру (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 дюйм жапсырма (101,6 × 152,4 мм)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Әр бетті басылған аймағына дейін қиып, тік бұрып, созбай параққа сыйғызады. Әр параққа бір жапсырма.
Find untagged content Тегтелмеген мазмұнды табу
No untagged content was found. Тегтелмеген мазмұн табылмады.
Found {n} untagged content problems. Тегтелмеген мазмұнға қатысты {n} мәселе табылды.
Tag form fields Пішін өрістерін тегтеу
Mark as artifact Артефакт ретінде белгілеу
Remove this leaf tag from reading order and mark its direct content as an artifact Осы жапырақ тегін оқу ретінен алып тастап, оның тікелей мазмұнын артефакт ретінде белгілеңіз
Mark content as artifact Мазмұнды артефакт ретінде белгілеу
Can't render this file because it contains an unexpected character in line 825 and column 9.
+14
View File
@@ -22,6 +22,7 @@
Auto ({percent}%) Automatiškai ({percent} %)
Identity Tapatybė
Name on new comments Naujų komentarų autoriaus vardas
Hide hover previews for highlights without comment text Slėpti užvedimo peržiūras paryškinimams be komentaro teksto
Open… Atverti…
Open Recent Atverti paskiausiai naudotus failus
Clear Recent Files Išvalyti paskiausiai naudotų failų sąrašą
@@ -376,6 +377,8 @@
Closed arrow Uždara rodyklė
Collapse Suskleisti
Collapse all Suskleisti viską
Collapse all pages Suskleisti visus puslapius
Expand all pages Išskleisti visus puslapius
Collapse all bookmarks Suskleisti visas žymeles
Collapse tag Suskleisti žymą
Collate Rūšiuoti komplektais
@@ -2600,6 +2603,10 @@ key Esc Esc
key Home Home
key End End
key Space Tarpas
Cut Image Iškirpti paveikslėlį
Redact Image Pašalinti konfidencialią informaciją iš paveikslėlio
Create Link… Kurti saitą…
Recognize Text… Atpažinti tekstą…
Minimize Sumažinti
Maximize Išdidinti
Restore Atkurti
@@ -2617,6 +2624,13 @@ key Space Tarpas
This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą:
Move tag earlier Perkelti žymą anksčiau
Move tag later Perkelti žymą vėliau
Turn upside down (180°) Apversti aukštyn kojomis (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 col. etiketė (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Apkerpa kiekvieną puslapį iki spausdinamos srities, pasuka tiesiai ir pritaiko lapui neiškraipydama. Viena etiketė lape.
Find untagged content Rasti nepažymėtą turinį
No untagged content was found. Nepažymėto turinio nerasta.
Found {n} untagged content problems. Rasta nepažymėto turinio problemų: {n}.
Tag form fields Žymėti formos laukus
Mark as artifact Pažymėti kaip artefaktą
Remove this leaf tag from reading order and mark its direct content as an artifact Pašalinkite šią lapo žymą iš skaitymo tvarkos ir pažymėkite jos tiesioginį turinį kaip artefaktą
Mark content as artifact Pažymėti turinį kaip artefaktą
Can't render this file because it contains an unexpected character in line 404 and column 9.
+14
View File
@@ -26,6 +26,7 @@
Interface language Język interfejsu
Identity Tożsamość
Name on new comments Autor nowych komentarzy
Hide hover previews for highlights without comment text Ukryj podglądy po najechaniu dla wyróżnień bez tekstu komentarza
Open… Otwórz…
Open Recent Otwórz ostatnie
Clear Recent Files Wyczyść listę ostatnich plików
@@ -1309,6 +1310,8 @@
Expand all bookmarks Rozwiń wszystkie zakładki
Expand top-level bookmarks Rozwiń zakładki najwyższego poziomu
Collapse all bookmarks Zwiń wszystkie zakładki
Collapse all pages Zwiń wszystkie strony
Expand all pages Rozwiń wszystkie strony
New bookmark (⌘B) Nowa zakładka (⌘B)
This document has no bookmarks. Ten dokument nie zawiera zakładek.
This document has no layers. Ten dokument nie zawiera warstw.
@@ -2336,6 +2339,10 @@ animation effects Off Wyłączone
Local cross-PDF links require the desktop app Lokalne łącza między plikami PDF wymagają aplikacji komputerowej
The existing action will be preserved. Istniejąca akcja zostanie zachowana.
This form's scripts are turned off because one ran too long. Reopen the document to run them again. Skrypty tego formularza są wyłączone, ponieważ jeden z nich działał zbyt długo. Otwórz dokument ponownie, aby znów je uruchomić.
Cut Image Wytnij obraz
Redact Image Utajnij obraz
Create Link… Utwórz łącze…
Recognize Text… Rozpoznaj tekst…
Minimize Zminimalizuj
Maximize Zmaksymalizuj
Restore Przywróć
@@ -2353,6 +2360,13 @@ animation effects Off Wyłączone
This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny:
Move tag earlier Przenieś znacznik wcześniej
Move tag later Przenieś znacznik później
Turn upside down (180°) Obróć do góry nogami (180°)
4 x 6 in label (101.6 x 152.4 mm) Etykieta 4 × 6 cali (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Przycina każdą stronę do zadrukowanego obszaru, obraca ją pionowo i dopasowuje do arkusza bez rozciągania. Jedna etykieta na arkusz.
Find untagged content Znajdź nieoznaczoną zawartość
No untagged content was found. Nie znaleziono nieoznaczonej zawartości.
Found {n} untagged content problems. Znaleziono problemów z nieoznaczoną zawartością: {n}.
Tag form fields Oznacz pola formularza
Mark as artifact Oznacz jako artefakt
Remove this leaf tag from reading order and mark its direct content as an artifact Usuń ten znacznik liścia z kolejności odczytu i oznacz jego bezpośrednią zawartość jako artefakt
Mark content as artifact Oznacz zawartość jako artefakt
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -23,6 +23,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Automático segue o tamanho do texto do sistema. As páginas sempre são exibidas na resolução total da tela.
Identity Identidade
Name on new comments Nome nos novos comentários
Hide hover previews for highlights without comment text Ocultar as prévias ao passar o ponteiro em destaques sem texto de comentário
Open… Abrir…
Open Recent Abrir recentes
Clear Recent Files Limpar arquivos recentes
@@ -2257,6 +2258,8 @@ key Space Espaço
Switch to List view to change the pages it takes. Mude para a exibição em lista para alterar as páginas incluídas.
Expand all Expandir tudo
Collapse all Recolher tudo
Collapse all pages Recolher todas as páginas
Expand all pages Expandir todas as páginas
Remove page Remover página
Remove {n} pages Remover {n} páginas
@@ -2370,6 +2373,10 @@ animation effects Off Desativados
The linked PDF opened, but its named destination was not applied. O PDF vinculado foi aberto, mas seu destino nomeado não foi aplicado.
The linked PDF opened, but its page destination was out of range. O PDF vinculado foi aberto, mas sua página de destino está fora do intervalo.
Local cross-PDF links require the desktop app Links locais entre PDFs exigem o aplicativo para desktop
Cut Image Recortar imagem
Redact Image Ocultar dados da imagem
Create Link… Criar link…
Recognize Text… Reconhecer texto…
Minimize Minimizar
Maximize Maximizar
Restore Restaurar
@@ -2387,6 +2394,13 @@ animation effects Off Desativados
This document is trying to open a local file: Este documento está tentando abrir um arquivo local:
Move tag earlier Mover tag para antes
Move tag later Mover tag para depois
Turn upside down (180°) Virar de cabeça para baixo (180°)
4 x 6 in label (101.6 x 152.4 mm) Etiqueta 4 × 6 pol (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Recorta cada página na área impressa, deixa-a na vertical e ajusta-a à folha sem distorcer. Uma etiqueta por folha.
Find untagged content Encontrar conteúdo sem marcação
No untagged content was found. Nenhum conteúdo sem marcação foi encontrado.
Found {n} untagged content problems. Foram encontrados {n} problemas de conteúdo sem marcação.
Tag form fields Marcar campos de formulário
Mark as artifact Marcar como artefato
Remove this leaf tag from reading order and mark its direct content as an artifact Remova esta tag folha da ordem de leitura e marque seu conteúdo direto como artefato
Mark content as artifact Marcar conteúdo como artefato
Can't render this file because it contains an unexpected character in line 9 and column 16.
+14
View File
@@ -22,6 +22,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. В режиме «Авто» используется размер текста из настроек системы. Страницы всегда отображаются в полном разрешении экрана.
Identity Идентификация
Name on new comments Имя в новых комментариях
Hide hover previews for highlights without comment text Скрывать всплывающий просмотр для выделений без текста комментария
Open… Открыть…
Open Recent Открыть недавние
Clear Recent Files Очистить недавние файлы
@@ -2172,6 +2173,8 @@ key Space Пробел
Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять.
Expand all Развернуть все
Collapse all Свернуть все
Collapse all pages Свернуть все страницы
Expand all pages Развернуть все страницы
Remove page Удалить страницу
Remove {n} pages Удалить страницы: {n}
@@ -2269,6 +2272,10 @@ animation effects Off Выкл
The linked PDF opened, but its named destination was not applied. Связанный PDF открыт, но его именованное назначение не применено.
The linked PDF opened, but its page destination was out of range. Связанный PDF открыт, но страница назначения вне допустимого диапазона.
Local cross-PDF links require the desktop app Локальные ссылки между PDF работают только в настольном приложении
Cut Image Вырезать изображение
Redact Image Скрыть изображение
Create Link… Создать ссылку…
Recognize Text… Распознать текст…
Minimize Свернуть
Maximize Развернуть
Restore Восстановить
@@ -2286,6 +2293,13 @@ animation effects Off Выкл
This document is trying to open a local file: Этот документ пытается открыть локальный файл:
Move tag earlier Переместить тег раньше
Move tag later Переместить тег позже
Turn upside down (180°) Перевернуть (180°)
4 x 6 in label (101.6 x 152.4 mm) Этикетка 4 × 6 дюйма (101,6 × 152,4 мм)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрезает каждую страницу по напечатанной области, разворачивает её прямо и вписывает в лист без искажений. Одна этикетка на лист.
Find untagged content Найти нетегированный контент
No untagged content was found. Нетегированный контент не найден.
Found {n} untagged content problems. Найдено проблем с нетегированным контентом: {n}.
Tag form fields Пометить поля формы
Mark as artifact Пометить как артефакт
Remove this leaf tag from reading order and mark its direct content as an artifact Удалите этот конечный тег из порядка чтения и пометьте его непосредственное содержимое как артефакт
Mark content as artifact Пометить содержимое как артефакт
Can't render this file because it contains an unexpected character in line 9 and column 16.
+14
View File
@@ -1121,6 +1121,7 @@
Name Ime
Name (A–Z) Ime (A–Ž)
Name on new comments Ime na novih komentarjih
Hide hover previews for highlights without comment text Skrij predoglede ob lebdenju za označitve brez besedila komentarja
Name: Ime:
Named Destinations Poimenovani cilji
Navigation links Navigacijske povezave
@@ -2191,6 +2192,8 @@ signature pad Type Vtipkaj
Auto follows the system's text size. Pages always render at the display's full resolution. Samodejno sledi velikosti besedila v sistemu. Strani se vedno izrišejo v polni ločljivosti zaslona.
Check the pages Preveri strani
Collapse all Strni vse
Collapse all pages Strni vse strani
Expand all pages Razširi vse strani
Column select Izbira stolpcev
Drag the selection to move it. Esc clears the selection. Povlecite izbor, da ga premaknete. Esc počisti izbor.
Expand all Razširi vse
@@ -2361,6 +2364,10 @@ animation effects Off Izklopljeni
The linked PDF opened, but its named destination was not applied. Povezani PDF se je odprl, vendar njegov poimenovani cilj ni bil uporabljen.
The linked PDF opened, but its page destination was out of range. Povezani PDF se je odprl, vendar je ciljna stran zunaj obsega.
Local cross-PDF links require the desktop app Lokalne povezave med PDF-ji zahtevajo namizno aplikacijo
Cut Image Izreži sliko
Redact Image Počrni sliko
Create Link… Ustvari povezavo…
Recognize Text… Prepoznaj besedilo…
Minimize Pomanjšaj
Maximize Povečaj
Restore Obnovi
@@ -2378,6 +2385,13 @@ animation effects Off Izklopljeni
This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko:
Move tag earlier Premakni oznako prej
Move tag later Premakni oznako pozneje
Turn upside down (180°) Obrni na glavo (180°)
4 x 6 in label (101.6 x 152.4 mm) Nalepka 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Vsako stran obreže na natisnjeno območje, jo poravna pokonci in prilagodi listu brez raztegovanja. Ena nalepka na list.
Find untagged content Poišči neoznačeno vsebino
No untagged content was found. Neoznačene vsebine ni bilo mogoče najti.
Found {n} untagged content problems. Najdenih je {n} težav z neoznačeno vsebino.
Tag form fields Označi polja obrazca
Mark as artifact Označi kot artefakt
Remove this leaf tag from reading order and mark its direct content as an artifact Odstranite to listno oznako iz vrstnega reda branja in označite njeno neposredno vsebino kot artefakt
Mark content as artifact Označi vsebino kot artefakt
Can't render this file because it contains an unexpected character in line 9 and column 16.
+14
View File
@@ -27,6 +27,7 @@ comment menu Edit సవరించు
Auto follows the system's text size. Pages always render at the display's full resolution. ఆటో సిస్టమ్ టెక్స్ట్ పరిమాణాన్ని అనుసరిస్తుంది. పేజీలు ఎల్లప్పుడూ డిస్‌ప్లే పూర్తి రిజల్యూషన్‌లో చూపబడతాయి.
Identity గుర్తింపు
Name on new comments కొత్త వ్యాఖ్యలపై చూపే పేరు
Hide hover previews for highlights without comment text వ్యాఖ్య వచనం లేని హైలైట్‌లకు హోవర్ ప్రివ్యూలను దాచు
Open… తెరువు…
New blank PDF కొత్త ఖాళీ PDF
Create PDF from file… ఫైల్ నుండి PDF సృష్టించు…
@@ -2180,6 +2181,8 @@ key Space స్పేస్
Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి
Expand all అన్నీ విస్తరించు
Collapse all అన్నీ ముడుచు
Collapse all pages అన్ని పేజీలను ముడుచు
Expand all pages అన్ని పేజీలను విస్తరించు
Remove page పేజీని తీసివేయి
Remove {n} pages {n} పేజీలను తీసివేయి
@@ -2269,6 +2272,10 @@ animation effects Off ఆఫ్
Local cross-PDF links require the desktop app PDFల మధ్య స్థానిక లింక్‌లకు డెస్క్‌టాప్ యాప్ అవసరం
The existing action will be preserved. ఉన్న చర్య అలాగే ఉంచబడుతుంది.
This form's scripts are turned off because one ran too long. Reopen the document to run them again. ఒక స్క్రిప్ట్ చాలా సేపు నడిచినందున ఈ ఫారమ్ స్క్రిప్ట్‌లు ఆపివేయబడ్డాయి. వాటిని మళ్లీ నడపడానికి పత్రాన్ని మళ్లీ తెరవండి.
Cut Image చిత్రాన్ని కత్తిరించు
Redact Image చిత్రాన్ని రెడాక్ట్ చేయి
Create Link… లింక్ సృష్టించు…
Recognize Text… టెక్స్ట్‌ను గుర్తించు…
Minimize కనిష్ఠీకరించు
Maximize గరిష్ఠీకరించు
Restore పునరుద్ధరించు
@@ -2286,6 +2293,13 @@ animation effects Off ఆఫ్
This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది:
Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు
Move tag later ట్యాగ్‌ను వెనుకకు తరలించు
Turn upside down (180°) తలకిందులుగా తిప్పు (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 అం. లేబుల్ (101.6 × 152.4 మి.మీ.)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. ప్రతి పేజీని ముద్రిత ప్రాంతానికి కత్తిరించి, నిటారుగా తిప్పి, సాగదీయకుండా షీట్‌కు సరిపెడుతుంది. ఒక్కో షీట్‌కు ఒక లేబుల్.
Find untagged content ట్యాగ్ చేయని కంటెంట్‌ను కనుగొను
No untagged content was found. ట్యాగ్ చేయని కంటెంట్ కనుగొనబడలేదు.
Found {n} untagged content problems. ట్యాగ్ చేయని కంటెంట్‌లో {n} సమస్యలు కనుగొనబడ్డాయి.
Tag form fields ఫారమ్ ఫీల్డ్‌లకు ట్యాగ్‌లు జోడించు
Mark as artifact ఆర్టిఫాక్ట్‌గా గుర్తించు
Remove this leaf tag from reading order and mark its direct content as an artifact ఈ లీఫ్ ట్యాగ్‌ను పఠన క్రమం నుంచి తీసివేసి, దాని ప్రత్యక్ష కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించండి
Mark content as artifact కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+14
View File
@@ -15,6 +15,7 @@
Interface language Arayüz dili
Identity Kimlik
Name on new comments Yeni yorumlardaki ad
Hide hover previews for highlights without comment text Yorum metni olmayan vurgular için üzerine gelme önizlemelerini gizle
Open… Aç…
New blank PDF Yeni boş PDF
Create PDF from file… Dosyadan PDF oluştur…
@@ -2214,6 +2215,8 @@ key Space Boşluk
Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Bazı yer imleri burada gösterilmiyor: panel sınırlı sayıda ve sınırlı derinlikte listeler.
Expand all Tümünü genişlet
Collapse all Tümünü daralt
Collapse all pages Tüm sayfaları daralt
Expand all pages Tüm sayfaları genişlet
Remove page Sayfayı kaldır
Remove {n} pages {n} sayfayı kaldır
@@ -2352,6 +2355,10 @@ animation effects Off Kapalı
The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı.
The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında.
Local cross-PDF links require the desktop app PDF'ler arası yerel bağlantılar masaüstü uygulamasını gerektirir
Cut Image Resmi Kes
Redact Image Resmi karart
Create Link… Bağlantı oluştur…
Recognize Text… Metni Tanı…
Minimize Küçült
Maximize Büyüt
Restore Geri yükle
@@ -2369,6 +2376,13 @@ animation effects Off Kapalı
This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor:
Move tag earlier Etiketi daha erken taşı
Move tag later Etiketi daha geç taşı
Turn upside down (180°) Baş aşağı çevir (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 inç etiket (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Her sayfayı basılı alanına göre kırpar, dik çevirir ve germeden sayfaya sığdırır. Sayfa başına bir etiket.
Find untagged content Etiketlenmemiş içeriği bul
No untagged content was found. Etiketlenmemiş içerik bulunamadı.
Found {n} untagged content problems. {n} etiketlenmemiş içerik sorunu bulundu.
Tag form fields Form alanlarını etiketle
Mark as artifact Yapıt olarak işaretle
Remove this leaf tag from reading order and mark its direct content as an artifact Bu yaprak etiketini okuma sırasından kaldırın ve doğrudan içeriğini yapıt olarak işaretleyin
Mark content as artifact İçeriği yapıt olarak işaretle
Can't render this file because it contains an unexpected character in line 824 and column 9.
+14
View File
@@ -32,6 +32,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. Автоматичний режим використовує розмір тексту із системних налаштувань. Сторінки завжди відображаються в повній роздільній здатності екрана.
Identity Особисті дані
Name on new comments Ім’я в нових коментарях
Hide hover previews for highlights without comment text Приховувати спливний перегляд для виділень без тексту коментаря
Open… Відкрити…
Open Recent Відкрити нещодавні
Pin folder to Home… Закріпити теку на головній…
@@ -2279,6 +2280,8 @@ key Space Пробіл
Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти.
Expand all Розгорнути всі
Collapse all Згорнути всі
Collapse all pages Згорнути всі сторінки
Expand all pages Розгорнути всі сторінки
Remove page Видалити сторінку
Remove {n} pages Видалити сторінки: {n}
@@ -2378,6 +2381,10 @@ animation effects Off Вимкнено
The linked PDF opened, but its named destination was not applied. Пов’язаний PDF відкрито, але його іменоване призначення не застосовано.
The linked PDF opened, but its page destination was out of range. Пов’язаний PDF відкрито, але сторінка призначення поза межами діапазону.
Local cross-PDF links require the desktop app Локальні посилання між PDF працюють лише в настільному застосунку
Cut Image Вирізати зображення
Redact Image Вилучити вміст зображення
Create Link… Створити посилання…
Recognize Text… Розпізнати текст…
Minimize Згорнути
Maximize Розгорнути
Restore Відновити
@@ -2395,6 +2402,13 @@ animation effects Off Вимкнено
This document is trying to open a local file: Цей документ намагається відкрити локальний файл:
Move tag earlier Перемістити тег раніше
Move tag later Перемістити тег пізніше
Turn upside down (180°) Перевернути (180°)
4 x 6 in label (101.6 x 152.4 mm) Етикетка 4 × 6 дюйма (101,6 × 152,4 мм)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрізає кожну сторінку за надрукованою областю, розвертає її прямо та вписує в аркуш без спотворень. Одна етикетка на аркуш.
Find untagged content Знайти нетегований вміст
No untagged content was found. Нетегованого вмісту не знайдено.
Found {n} untagged content problems. Знайдено проблем із нетегованим вмістом: {n}.
Tag form fields Позначити поля форми
Mark as artifact Позначити як артефакт
Remove this leaf tag from reading order and mark its direct content as an artifact Вилучіть цей кінцевий тег із порядку читання та позначте його безпосередній вміст як артефакт
Mark content as artifact Позначити вміст як артефакт
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -25,6 +25,7 @@
Auto ({percent}%) Tự động ({percent}%)
Identity Danh tính
Name on new comments Tên trên bình luận mới
Hide hover previews for highlights without comment text Ẩn bản xem trước khi di chuột cho các phần tô sáng không có nội dung nhận xét
Open… Mở…
Open Recent Mở gần đây
Clear Recent Files Xóa tệp gần đây
@@ -373,6 +374,8 @@ theme Appearance Giao diện
Closed arrow Mũi tên đóng
Collapse Thu gọn
Collapse all Thu gọn tất cả
Collapse all pages Thu gọn tất cả các trang
Expand all pages Mở rộng tất cả các trang
Collapse all bookmarks Thu gọn tất cả dấu trang
Collapse tag Thu gọn thẻ
Collate Sắp theo bộ
@@ -2592,6 +2595,7 @@ key End End
key Space Space
Move tag earlier Chuyển thẻ lên trước
Move tag later Chuyển thẻ ra sau
Tag form fields Gắn thẻ các trường biểu mẫu
Only allow this if you trust the source document. Chỉ cho phép nếu bạn tin tưởng tài liệu nguồn.
Open this local PDF? Mở PDF cục bộ này?
This document is trying to open a local file: Tài liệu này đang cố mở một tệp cục bộ:
@@ -2601,3 +2605,13 @@ key Space Space
Restore Khôi phục
Use system title bar Dùng thanh tiêu đề hệ thống
Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Hiện thanh tiêu đề đơn giản cho các nút cửa sổ thay vì dải tab. GNOME không vẽ thanh tiêu đề cho ứng dụng, nên thanh này không theo chủ đề của nó.
Turn upside down (180°) Lật ngược (180°)
4 x 6 in label (101.6 x 152.4 mm) Nhãn 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Cắt mỗi trang theo vùng được in, xoay thẳng và vừa với tờ giấy mà không kéo giãn. Mỗi tờ một nhãn.
Find untagged content Tìm nội dung chưa được gắn thẻ
No untagged content was found. Không tìm thấy nội dung nào chưa được gắn thẻ.
Found {n} untagged content problems. Đã tìm thấy {n} vấn đề về nội dung chưa được gắn thẻ.
Cut Image Cắt hình ảnh
Redact Image Xóa nội dung nhạy cảm của hình ảnh
Create Link… Tạo liên kết…
Recognize Text… Nhận dạng văn bản…
Can't render this file because it contains an unexpected character in line 490 and column 12.
+14
View File
@@ -63,6 +63,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. “自动”会跟随系统的文字大小。页面始终以显示器的完整分辨率渲染。
Identity 身份
Name on new comments 新注释的作者姓名
Hide hover previews for highlights without comment text 隐藏无评论文字的高亮的悬停预览
Open… 打开…
Open Recent 打开最近的文件
Clear Recent Files 清除最近的文件
@@ -2223,6 +2224,8 @@ key Space 空格
Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。
Expand all 全部展开
Collapse all 全部折叠
Collapse all pages 折叠所有页面
Expand all pages 展开所有页面
Remove page 删除页面
Remove {n} pages 删除 {n} 页
@@ -2320,6 +2323,10 @@ animation effects Off 关
The linked PDF opened, but its named destination was not applied. 已打开链接的 PDF,但未应用其命名目标。
The linked PDF opened, but its page destination was out of range. 已打开链接的 PDF,但其页面目标超出范围。
Local cross-PDF links require the desktop app PDF 之间的本地链接需要桌面应用
Cut Image 剪切图像
Redact Image 密文图像
Create Link… 创建链接…
Recognize Text… 识别文本…
Minimize 最小化
Maximize 最大化
Restore 还原
@@ -2337,6 +2344,13 @@ animation effects Off 关
This document is trying to open a local file: 此文档正尝试打开本地文件:
Move tag earlier 将标签前移
Move tag later 将标签后移
Turn upside down (180°) 上下颠倒 (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 英寸标签 (101.6 × 152.4 毫米)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 将每页裁切到其打印区域,转正后不拉伸地适合纸张。每张纸一个标签。
Find untagged content 查找未标记的内容
No untagged content was found. 未找到未标记的内容。
Found {n} untagged content problems. 发现 {n} 个未标记内容问题。
Tag form fields 标记表单字段
Mark as artifact 标记为工件
Remove this leaf tag from reading order and mark its direct content as an artifact 从阅读顺序中移除此叶标签,并将其直接内容标记为工件
Mark content as artifact 将内容标记为工件
Can't render this file because it contains an unexpected character in line 9 and column 16.
+14
View File
@@ -22,6 +22,7 @@
Auto follows the system's text size. Pages always render at the display's full resolution. 「自動」會跟隨系統的文字大小。頁面一律以螢幕的完整解析度呈現。
Identity 個人資訊
Name on new comments 新註解的作者名稱
Hide hover previews for highlights without comment text 隱藏沒有註解文字的螢光標示的停留預覽
Open… 開啟…
Open Recent 開啟最近的檔案
Clear Recent Files 清除最近的檔案
@@ -2138,6 +2139,8 @@ key Space 空格
Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。
Expand all 全部展開
Collapse all 全部摺疊
Collapse all pages 摺疊所有頁面
Expand all pages 展開所有頁面
Remove page 刪除頁面
Remove {n} pages 刪除 {n} 頁
@@ -2232,6 +2235,10 @@ animation effects Off 關
The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。
Local cross-PDF links require the desktop app PDF 之間的本機連結需要桌面應用程式
The existing action will be preserved. 將保留現有動作。
Cut Image 剪下圖片
Redact Image 遮蔽圖片
Create Link… 建立連結…
Recognize Text… 辨識文字…
Minimize 最小化
Maximize 最大化
Restore 還原
@@ -2249,6 +2256,13 @@ animation effects Off 關
This document is trying to open a local file: 此文件正嘗試開啟本機檔案:
Move tag earlier 將標記前移
Move tag later 將標記後移
Turn upside down (180°) 上下顛倒 (180°)
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 英吋標籤 (101.6 × 152.4 公釐)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 將每頁裁切至其列印區域,轉正後不拉伸地符合紙張。每張紙一個標籤。
Find untagged content 尋找未標記的內容
No untagged content was found. 找不到未標記的內容。
Found {n} untagged content problems. 發現 {n} 個未標記內容問題。
Tag form fields 標記表單欄位
Mark as artifact 標記為成品
Remove this leaf tag from reading order and mark its direct content as an artifact 從閱讀順序中移除此葉標記,並將其直接內容標記為成品
Mark content as artifact 將內容標記為成品
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -467,6 +467,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
let label = ui.label(tl!("Name on new comments"));
ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(220.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id);
});
ui.add(widgets::checkbox(&mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text")));
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0)));
ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving")));
+12
View File
@@ -2277,6 +2277,8 @@ impl PdfCraftApp {
});
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
v["redact_prefs"] = self.redact_defaults.to_json();
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
v.to_string()
}
@@ -2378,6 +2380,9 @@ impl PdfCraftApp {
if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) {
self.comment_prefs.author = author.chars().take(MAX_AUTHOR_CHARS).collect();
}
if let Some(hide) = v["hide_empty_highlight_hover"].as_bool() {
self.comment_prefs.hide_empty_highlight_hover = hide;
}
// Per-tool comment styles (#340): each field goes through its clamping setter, so a
// malformed or hostile entry is clamped or ignored rather than trusted.
if let Some(styles) = v["comment_styles"].as_array() {
@@ -2687,6 +2692,13 @@ impl PdfCraftApp {
};
}
("author", _) => self.comment_prefs.author = value.to_string(),
("hide-empty-highlight-hover", _) => {
self.comment_prefs.hide_empty_highlight_hover = match value {
"true" => true,
"false" => false,
_ => return Err("hide-empty-highlight-hover must be true or false".into()),
};
}
("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?,
("system-title-bar", _) => {
self.system_title_bar = match value {
+2 -1
View File
@@ -702,7 +702,8 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
RightPanel::Tags => {
let tags = tags_state.tags(doc);
let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, tags_state, doc.allows_modification());
let content = tags_state.content(doc);
let (page, edit) = crate::tags_ui::panel(ui, &t, id, &tags, &content, tags_state, doc.allows_modification());
if let Some(page) = page {
nav = Some(Nav::Page(page));
}
+89 -3
View File
@@ -14,6 +14,9 @@ const PREVIEW_W: f32 = 320.0;
const PREVIEW_H: f32 = 380.0;
const PREVIEW_INSET: f32 = 12.0;
pub(crate) const PREVIEW_BOX: (f32, f32) = (PREVIEW_W - 2.0 * PREVIEW_INSET, PREVIEW_H - 2.0 * PREVIEW_INSET);
/// Label printing reads the printed area of at most this many pages (each is a quick render on
/// the UI thread); further pages print whole.
pub(crate) const LABEL_CROP_PAGES: usize = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Which {
@@ -30,6 +33,8 @@ pub enum Handling {
Poster,
Multiple,
Booklet,
/// Shipping label: cut to the printed area, turned upright, one per sheet.
Label,
}
#[derive(Clone, Debug, PartialEq)]
@@ -81,6 +86,13 @@ pub struct PrintDraft {
pub show_driver_options: bool,
/// Why the printer's preferences window didn't open (Windows).
pub driver_error: Option<String>,
/// Label: turn a further 180°.
pub label_flip: bool,
/// Label: each page's printed area and text direction, read when Label is chosen
/// ([`PdfCraftApp::read_label_crops`]); empty until then.
pub crops: Vec<Option<print::Crop>>,
/// The pages `crops` were read for, so a changed page selection reads them again.
pub crop_pages: Option<Vec<usize>>,
}
impl Default for PrintDraft {
@@ -123,6 +135,9 @@ impl Default for PrintDraft {
driver_choices: Default::default(),
show_driver_options: false,
driver_error: None,
label_flip: false,
crops: Vec::new(),
crop_pages: None,
}
}
}
@@ -160,6 +175,7 @@ impl PrintDraft {
},
Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding },
Handling::Poster => Layout::Poster { scale: self.poster_scale, overlap: self.overlap, cut_marks: self.cut_marks },
Handling::Label => Layout::Label { flip: self.label_flip },
};
Ok(print::Settings {
pages,
@@ -168,6 +184,7 @@ impl PrintDraft {
layout,
content: self.content,
raster: self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }),
crops: self.crops.clone(),
})
}
@@ -184,7 +201,11 @@ impl PrintDraft {
options: match (&self.printer, &self.driver_options) {
(Some(p), Some((read_for, _))) if p == read_for => self.driver_choices.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
_ => Vec::new(),
},
}
.into_iter()
// The sheet the PDF was laid out on, not the queue's default paper.
.chain(print::cups_media(self.paper).map(|m| ("media".to_string(), m.to_string())))
.collect(),
}
}
}
@@ -292,6 +313,8 @@ impl PdfCraftApp {
(true, other) => other,
};
self.print_draft = PrintDraft {
crops: Vec::new(),
crop_pages: None,
printers,
printers_loading,
printer_picked: false,
@@ -306,6 +329,50 @@ impl PdfCraftApp {
self.dialog = Some(crate::Dialog::Print);
}
/// Label printing: find each printed page's printed area (a quick low-resolution render, as
/// Fit Visible does) and which way its text runs. Only the pages being printed are read, at
/// most [`LABEL_CROP_PAGES`] of them (the rest print whole); read again when they change.
pub(crate) fn read_label_crops(&mut self, id: pdfcraft_engine::DocId) {
let Some(doc) = self.session.get(id) else { return };
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
let mut pages = self.print_draft.settings(doc.info.pages.len(), &labels).map(|s| s.pages).unwrap_or_default();
pages.sort_unstable();
pages.dedup();
pages.truncate(LABEL_CROP_PAGES);
if self.print_draft.crop_pages.as_ref() == Some(&pages) {
return;
}
let up = self.session.print_text_up(id, &pages);
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
self.print_draft.crops = doc
.info
.pages
.iter()
.enumerate()
.map(|(page, p)| {
let (w, h) = (f64::from(p.width), f64::from(p.height));
if pages.binary_search(&page).is_err() || w <= 0.0 || h <= 0.0 {
return None;
}
let out = r.render(pdfcraft_render::RenderRequest {
page,
kind: pdfcraft_render::RequestKind::Pixels,
tile: None,
scale: (1000.0 / w.max(h) as f32).clamp(0.1, 4.0),
tag: 0,
preview: false,
});
let [u0, v0, u1, v1] = crate::zoom_snap::ink_bounds(out.width as usize, out.height as usize, &out.rgba)?;
// Image rows run down from the top; display space runs up. Half a millimetre of slack.
let pad = 1.5;
let rect = [f64::from(u0) * w - pad, (1.0 - f64::from(v1)) * h - pad, f64::from(u1) * w + pad, (1.0 - f64::from(v0)) * h + pad];
Some(print::Crop { rect, text_up: up.get(page).copied().unwrap_or(false) })
})
.collect();
self.print_draft.crop_pages = Some(pages);
}
#[cfg(not(target_arch = "wasm32"))]
fn list_printers_in_background(&mut self, source: PrinterSource) {
// A list already being read is as fresh as a new one would be.
@@ -838,11 +905,17 @@ pub(crate) fn body(
ui.end_row();
ui.label(tl!("Paper:"));
let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0);
egui::ComboBox::from_id_salt("paper").selected_text(paper_name).width(260.0).show_ui(ui, |ui| {
let before = d.paper;
egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| {
for (i, (name, _)) in PAPERS.iter().enumerate() {
ui.selectable_value(&mut d.paper, i, *name);
ui.selectable_value(&mut d.paper, i, tl!(name));
}
});
// Label stock gets the label layout (cut, turned upright, fitted).
if d.paper != before && d.paper == print::LABEL_PAPER {
d.handling = Handling::Label;
d.sheet = 0;
}
ui.end_row();
});
// Windows says which drivers can't print two-sided as they're asked; they keep their
@@ -914,10 +987,15 @@ pub(crate) fn body(
(Handling::Poster, tl!("Poster")),
(Handling::Multiple, tl!("Multiple")),
(Handling::Booklet, tl!("Booklet")),
(Handling::Label, tl!("Label")),
] {
if widgets::mode_tab(ui, label, d.handling == h).clicked() {
d.handling = h;
d.sheet = 0;
// A label goes on label stock.
if h == Handling::Label {
d.paper = print::LABEL_PAPER;
}
}
}
});
@@ -937,6 +1015,14 @@ pub(crate) fn body(
ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %"));
});
}
Handling::Label => {
ui.add(widgets::checkbox(&mut d.label_flip, tl!("Turn upside down (180°)")));
ui.label(
egui::RichText::new(tl!("Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet."))
.small()
.color(t.text_muted),
);
}
Handling::Poster => {
ui.horizontal(|ui| {
ui.label(tl!("Tile scale:"));
+45 -1
View File
@@ -13,6 +13,9 @@ use std::sync::{Arc, OnceLock};
use egui::FontData;
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
mod fontconfig;
/// Larger files are not read: a font path is still untrusted input.
const MAX_BYTES: u64 = 32 << 20;
/// Chinese system faces are big collections (PingFang, STHeiti), so they get a higher cap.
@@ -291,7 +294,37 @@ fn windows_fonts() -> PathBuf {
}
fn unix_fonts(files: &'static [&'static str]) -> Vec<PathBuf> {
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect()
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
static INSTALLED: OnceLock<Vec<PathBuf>> = OnceLock::new();
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
// Read once per process, and not at all when installed fonts are turned off.
let installed = INSTALLED
.get_or_init(|| {
if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
return Vec::new();
}
fontconfig::font_files(&fontconfig::config_path())
})
.as_slice();
#[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))]
let installed: &[PathBuf] = &[];
unix_candidates(files, installed)
}
fn unix_candidates(files: &[&str], installed: &[PathBuf]) -> Vec<PathBuf> {
let mut paths: Vec<_> =
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect();
// Fontconfig may point into /nix/store or a user's font directory. Keep the existing
// candidate names and their preference order; this does not select arbitrary fonts.
for file in files {
let name = Path::new(file).file_name();
for path in installed.iter().filter(|p| p.file_name() == name) {
if !paths.contains(path) {
paths.push(path.clone());
}
}
}
paths
}
fn read(path: &Path, probe: char, max: u64) -> Option<Arc<FontData>> {
@@ -318,6 +351,17 @@ fn face_with(bytes: &[u8], c: char) -> Option<u32> {
mod tests {
use super::*;
#[test]
fn configured_candidates_preserve_names_order_and_existing_paths() {
let names = ["noto/NotoSansArabic-Regular.ttf", "truetype/dejavu/DejaVuSans.ttf"];
let noto = PathBuf::from("/custom/fonts/NotoSansArabic-Regular.ttf");
let dejavu = PathBuf::from("/nix/store/font/share/fonts/truetype/DejaVuSans.ttf");
let installed = [dejavu.clone(), PathBuf::from("/custom/fonts/SourceHanSans.ttf"), noto.clone(), dejavu.clone()];
let mut expected = unix_candidates(&names, &[]);
expected.extend([noto, dejavu]);
assert_eq!(unix_candidates(&names, &installed), expected);
}
#[test]
fn broken_and_missing_files_are_skipped() {
assert_eq!(face_with(b"", PROBE), None);
@@ -0,0 +1,329 @@
//! Discover font files in configured directories, including Nix store paths.
//!
//! Only the existing fallback filenames are selected by the caller. Font bytes are still
//! checked for size and glyph coverage there; no font is copied or embedded.
use std::collections::HashSet;
use std::io::Read as _;
use std::path::{Path, PathBuf};
// A configured directory may contain unrelated files or symlink cycles. Bound the walk.
const MAX_ENTRIES: usize = 32_768;
const MAX_DEPTH: usize = 16;
const MAX_CONFIGS: usize = 256;
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
#[derive(Default)]
struct Directories {
dirs: Vec<PathBuf>,
seen: HashSet<PathBuf>,
entries: usize,
}
impl Directories {
fn include(&mut self, path: &Path, depth: usize) {
if depth > MAX_DEPTH || self.seen.len() >= MAX_CONFIGS {
return;
}
let Ok(canonical) = path.canonicalize() else { return };
if self.seen.contains(&canonical) {
return;
}
if canonical.is_dir() {
self.seen.insert(canonical);
let Ok(entries) = std::fs::read_dir(path) else { return };
let mut configs = Vec::new();
for entry in entries {
if self.entries >= MAX_ENTRIES {
break;
}
self.entries += 1;
let Ok(entry) = entry else { continue };
// Fontconfig directory includes accept digit-prefixed *.conf only.
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.as_bytes().first().is_some_and(u8::is_ascii_digit) && name.ends_with(".conf") && entry.path().is_file() {
configs.push(entry.path());
}
}
configs.sort();
for config in configs {
self.include(&config, depth + 1);
}
return;
}
if !canonical.is_file() {
return;
}
self.seen.insert(canonical);
let Ok(file) = std::fs::File::open(path) else { return };
let mut bytes = Vec::new();
if file.take(MAX_CONFIG_BYTES + 1).read_to_end(&mut bytes).is_err() || bytes.len() as u64 > MAX_CONFIG_BYTES {
return;
}
let Ok(xml) = String::from_utf8(bytes) else { return };
let parts = match fontconfig_parser::parse_config_parts(&xml) {
Ok(parts) => parts,
Err(error) => {
log::debug!("interface font directories: {}: {error}", path.display());
return;
}
};
for part in parts {
use fontconfig_parser::{ConfigPart, DirPrefix};
match part {
ConfigPart::Dir(dir) if self.dirs.len() < MAX_ENTRIES => self.dirs.push(dir.calculate_path(path)),
ConfigPart::ResetDirs => self.dirs.clear(),
ConfigPart::Include(mut include) => {
// The dependency's default include prefix hardcodes /etc/fonts.
// Resolve relative to this file, including nested and FONTCONFIG_PATH configs.
if include.prefix == DirPrefix::Default {
include.prefix = DirPrefix::Relative;
}
self.include(&include.calculate_path(path), depth + 1);
}
_ => {}
}
}
}
}
pub(super) fn config_path() -> PathBuf {
let file = std::env::var_os("FONTCONFIG_FILE").map_or_else(|| PathBuf::from("fonts.conf"), PathBuf::from);
if file.is_absolute() {
return file;
}
let dir =
std::env::var_os("FONTCONFIG_PATH").and_then(|paths| std::env::split_paths(&paths).next()).unwrap_or_else(|| PathBuf::from("/etc/fonts"));
dir.join(file)
}
pub(super) fn font_files(config_path: &Path) -> Vec<PathBuf> {
// Use the dependency's XML parser, but own include resolution and directory filtering.
// Missing or malformed configuration must not prevent the ordinary fallback paths.
let mut config = Directories::default();
config.include(config_path, 0);
let mut pending: Vec<_> = config.dirs.into_iter().map(|dir| (dir, 0)).collect();
let mut seen = HashSet::new();
let mut files = Vec::new();
let mut remaining = MAX_ENTRIES;
while let Some((dir, depth)) = pending.pop() {
let Ok(canonical) = dir.canonicalize() else { continue };
if !seen.insert(canonical) {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries {
if remaining == 0 {
files.sort();
return files;
}
remaining -= 1;
let Ok(entry) = entry else { continue };
let path = entry.path();
// Follow links to fonts and directories, but visit each directory only once.
if path.is_dir() {
if depth < MAX_DEPTH {
pending.push((path, depth + 1));
}
} else if path.extension().is_some_and(|ext| ext == "ttf" || ext == "ttc") && path.is_file() {
files.push(path);
}
}
}
files.sort();
files
}
#[cfg(test)]
mod tests {
use super::*;
// Fixtures contain only configuration and dummy files, never copied system fonts.
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("pdfcraft-fontconfig-{}-{n}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
fn config(&self, name: &str, body: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, format!("<fontconfig>{body}</fontconfig>")).unwrap();
path
}
fn isolated(&self, test: &str) {
// Environment selection is process-global: never mutate it in a parallel harness.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &format!("system_fonts::fontconfig::tests::{test}"), "--nocapture"])
.env("FONTCONFIG_PATH", &self.0)
.env("FONTCONFIG_FILE", "fonts.conf")
.env("PDFCRAFT_FONTCONFIG_TEST_ROOT", &self.0)
.output()
.unwrap();
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
}
fn font(&self) -> PathBuf {
let dir = self.0.join("fonts");
std::fs::create_dir(&dir).unwrap();
let font = dir.join("DejaVuSans.ttf");
std::fs::write(&font, b"dummy font for directory discovery").unwrap();
font
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn relative_includes_follow_each_including_config_under_fontconfig_path() {
if let Some(root) = std::env::var_os("PDFCRAFT_FONTCONFIG_TEST_ROOT") {
let root = PathBuf::from(root);
assert_eq!(config_path(), root.join("fonts.conf"));
assert_eq!(font_files(&config_path()), vec![root.join("fonts/DejaVuSans.ttf")]);
return;
}
let fixture = Fixture::new();
fixture.font();
std::fs::create_dir(fixture.0.join("conf.d")).unwrap();
std::fs::create_dir(fixture.0.join("nested")).unwrap();
fixture.config("fonts.conf", "<include>conf.d</include>");
fixture.config("conf.d/10-fonts.conf", "<include>../nested/next.conf</include>");
fixture.config("nested/next.conf", &format!("<dir>{}</dir>", fixture.0.join("fonts").display()));
fixture.isolated("relative_includes_follow_each_including_config_under_fontconfig_path");
}
#[test]
fn included_directories_ignore_disabled_backup_and_non_numbered_files() {
if let Some(root) = std::env::var_os("PDFCRAFT_FONTCONFIG_TEST_ROOT") {
let root = PathBuf::from(root);
assert_eq!(font_files(&config_path()), vec![root.join("fonts/DejaVuSans.ttf")]);
return;
}
for name in ["99-reset.conf.disabled", "99-reset.conf.bak", "reset.conf"] {
let fixture = Fixture::new();
fixture.font();
std::fs::create_dir(fixture.0.join("conf.d")).unwrap();
fixture.config("fonts.conf", &format!("<include>{}</include>", fixture.0.join("conf.d").display()));
fixture.config("conf.d/10-fonts.conf", &format!("<dir>{}</dir>", fixture.0.join("fonts").display()));
fixture.config(&format!("conf.d/{name}"), "<reset-dirs/>");
fixture.isolated("included_directories_ignore_disabled_backup_and_non_numbered_files");
}
}
#[test]
fn numbered_configs_keep_lexical_reset_order_and_include_cycles_terminate() {
let fixture = Fixture::new();
let font = fixture.font();
std::fs::create_dir(fixture.0.join("conf.d")).unwrap();
let config = fixture.config("fonts.conf", "<include>conf.d</include>");
fixture.config("conf.d/10-reset.conf", "<reset-dirs/>");
let external = fixture.config("external.conf", &format!("<dir>{}</dir><include>../fonts.conf</include>", fixture.0.join("fonts").display()));
std::os::unix::fs::symlink(external, fixture.0.join("conf.d/20-fonts.conf")).unwrap();
assert_eq!(font_files(&config), vec![font]);
fixture.config("conf.d/30-reset.conf", "<reset-dirs/>");
assert!(font_files(&config).is_empty());
}
#[test]
fn oversized_configs_are_skipped_without_losing_other_includes() {
let fixture = Fixture::new();
let font = fixture.font();
std::fs::create_dir(fixture.0.join("conf.d")).unwrap();
let config = fixture.config("fonts.conf", "<include>conf.d</include>");
std::fs::write(fixture.0.join("conf.d/10-large.conf"), vec![b' '; (MAX_CONFIG_BYTES + 1) as usize]).unwrap();
fixture.config("conf.d/20-fonts.conf", &format!("<dir>{}</dir>", fixture.0.join("fonts").display()));
assert_eq!(font_files(&config), vec![font]);
}
#[test]
fn deep_include_chains_are_bounded() {
let fixture = Fixture::new();
fixture.font();
for depth in 0..=MAX_DEPTH + 1 {
let body = if depth > MAX_DEPTH {
format!("<dir>{}</dir>", fixture.0.join("fonts").display())
} else {
format!("<include>depth-{}.conf</include>", depth + 1)
};
fixture.config(&format!("depth-{depth}.conf"), &body);
}
assert!(font_files(&fixture.0.join("depth-0.conf")).is_empty());
}
#[test]
fn wide_include_directories_are_bounded() {
let fixture = Fixture::new();
fixture.font();
std::fs::create_dir(fixture.0.join("conf.d")).unwrap();
let config = fixture.config("fonts.conf", "<include>conf.d</include>");
for n in 0..MAX_CONFIGS {
fixture.config(&format!("conf.d/{n:04}-empty.conf"), "");
}
fixture.config("conf.d/9999-fonts.conf", &format!("<dir>{}</dir>", fixture.0.join("fonts").display()));
assert!(font_files(&config).is_empty());
}
#[test]
fn included_configuration_finds_fonts_outside_standard_directories() {
let fixture = Fixture::new();
let fonts = fixture.0.join("nix-store-font/share/fonts/truetype");
std::fs::create_dir_all(&fonts).unwrap();
let font = fonts.join("DejaVuSans.ttf");
std::fs::write(&font, b"not parsed during discovery").unwrap();
std::fs::write(fonts.join("README.txt"), b"not a font").unwrap();
let included = fixture.config("nixos.conf", &format!("<dir>{}</dir>", fixture.0.join("nix-store-font").display()));
let config = fixture.config("fonts.conf", &format!("<include>{}</include>", included.display()));
assert_eq!(font_files(&config), vec![font]);
}
#[test]
fn missing_and_malformed_configuration_are_skipped() {
let fixture = Fixture::new();
assert!(font_files(&fixture.0.join("missing.conf")).is_empty());
let path = fixture.0.join("broken.conf");
std::fs::write(&path, "<fontconfig><dir>").unwrap();
assert!(font_files(&path).is_empty());
}
#[test]
fn symlinked_fonts_are_found_and_directory_cycles_terminate() {
let fixture = Fixture::new();
let fonts = fixture.0.join("fonts");
std::fs::create_dir(&fonts).unwrap();
let original = fixture.0.join("original.ttf");
std::fs::write(&original, b"dummy").unwrap();
let linked = fonts.join("DejaVuSans.ttf");
std::os::unix::fs::symlink(&original, &linked).unwrap();
std::os::unix::fs::symlink(&fonts, fonts.join("loop")).unwrap();
std::os::unix::fs::symlink(fixture.0.join("missing.ttf"), fonts.join("broken.ttf")).unwrap();
let config = fixture.config("fonts.conf", &format!("<dir>{}</dir><dir>{}</dir>", fonts.display(), fonts.display()));
assert_eq!(font_files(&config), vec![linked]);
}
#[test]
fn deeply_nested_directories_are_bounded() {
let fixture = Fixture::new();
let mut dir = fixture.0.join("fonts");
std::fs::create_dir(&dir).unwrap();
let shallow = dir.join("DejaVuSans.ttf");
std::fs::write(&shallow, b"dummy").unwrap();
for _ in 0..MAX_DEPTH + 1 {
dir.push("nested");
std::fs::create_dir(&dir).unwrap();
}
std::fs::write(dir.join("DejaVuSans.ttf"), b"dummy").unwrap();
let config = fixture.config("fonts.conf", &format!("<dir>{}</dir>", fixture.0.join("fonts").display()));
assert_eq!(font_files(&config), vec![shallow]);
}
}
+41 -2
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use egui::Ui;
use pdfcraft_cos::ObjRef;
use pdfcraft_engine::a11y::{TagInfo, TagPlacement, TagProperty};
use pdfcraft_engine::a11y::{TagContentItem, TagInfo, TagPlacement, TagProperty};
use pdfcraft_engine::{DocId, Edit};
use crate::{icons, theme::Tokens, widgets};
@@ -20,6 +20,9 @@ pub(crate) struct State {
/// The tag list per (document, edit generation): walking the structure tree is too slow
/// to repeat every frame on a large tagged PDF.
cache: Option<(DocId, u64, Arc<Vec<TagInfo>>)>,
/// The selected tag's marked content per (document, edit generation, tag): reading it parses
/// page content, so it isn't repeated every frame either.
content_cache: Option<(DocId, u64, ObjRef, Arc<Vec<TagContentItem>>)>,
}
#[derive(Clone, Debug, Default)]
@@ -64,6 +67,21 @@ impl Draft {
}
impl State {
/// The selected tag's marked content, read again only after an edit or a new selection.
pub(crate) fn content(&mut self, doc: &pdfcraft_engine::Document) -> Arc<Vec<TagContentItem>> {
let Some(object) = self.selected else { return Arc::default() };
let generation = doc.edit_generation();
if let Some((id, g, o, content)) = &self.content_cache
&& *id == doc.id
&& *g == generation
&& *o == object
{
return content.clone();
}
let content = Arc::new(doc.tag_content(object).unwrap_or_default());
self.content_cache = Some((doc.id, generation, object, content.clone()));
content
}
/// The document's tags, walked again only after an edit.
pub(crate) fn tags(&mut self, doc: &pdfcraft_engine::Document) -> Arc<Vec<TagInfo>> {
let generation = doc.edit_generation();
@@ -102,7 +120,15 @@ impl State {
}
/// The chosen page and an edit to apply after the panel releases its document borrow.
pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state: &mut State, can_edit: bool) -> (Option<usize>, Option<Edit>) {
pub(crate) fn panel(
ui: &mut Ui,
t: &Tokens,
doc: DocId,
tags: &[TagInfo],
content: &[TagContentItem],
state: &mut State,
can_edit: bool,
) -> (Option<usize>, Option<Edit>) {
if state.doc != Some(doc) {
state.doc = Some(doc);
state.selected = None;
@@ -200,6 +226,19 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state
.on_hover_text(tl!("Remove this leaf tag from reading order and mark its direct content as an artifact"))
.clicked();
});
ui.separator();
for item in content {
let page_label = item.page.map(|p| crate::i18n::fmt(tl!("Go to page {p}"), &[("p", &(p + 1).to_string())]));
let label = match (&page_label, item.text.trim()) {
(Some(page), text) if !text.is_empty() => format!("MCID {} · {page} · {text}", item.mcid),
(Some(page), _) => format!("MCID {} · {page}", item.mcid),
(_, text) if !text.is_empty() => format!("MCID {} · {text}", item.mcid),
_ => format!("MCID {}", item.mcid),
};
if ui.button(label).clicked() {
page = item.page;
}
}
(
page,
if save {
+48
View File
@@ -18,6 +18,14 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
const FORM_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 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] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [20 40 120 60] /P 3 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn status(h: &Harness<'static, PdfCraftApp>, rule: Rule) -> Status {
h.state().a11y.report.as_ref().unwrap().1.result(rule).unwrap().status
}
@@ -210,6 +218,28 @@ fn a_saved_page_range_past_the_document_still_checks_its_pages() {
assert_eq!(h.state().a11y.options.as_ref().and_then(|o| o.pages.clone()), Some(vec![0]));
}
#[test]
fn tagging_form_fields_opens_the_tags_panel() {
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, FORM_FIXTURE.to_vec()).unwrap();
app
});
h.run_steps(3);
assert!(h.state_mut().execute("a11y.autotag_fields"));
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Tags));
let id = h.state().views[0].id;
let report = h
.state()
.session
.get(id)
.unwrap()
.accessibility_check(&pdfcraft_engine::a11y::Options { rules: [Rule::TaggedFormFields].into(), pages: None });
assert_eq!(report.unwrap().result(Rule::TaggedFormFields).unwrap().status, Status::Passed);
}
/// Two tagged figures: an image without alternate text and a bar (role-mapped Photo) with.
const FIGURES: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj
@@ -297,6 +327,7 @@ fn tags_panel_edits_a_tag_property_and_undoes_it() {
h.run_steps(2);
h.get_by_label("Figure · 7").click();
h.run_steps(2);
h.get_by_label("MCID 0 · Go to page 1");
let title = h.get_by_role_and_label(Role::TextInput, "Title");
title.focus();
title.type_text("First figure");
@@ -386,3 +417,20 @@ fn checker_finding_opens_its_structure_tag() {
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Accessibility), "an unfinished tag edit is preserved");
}
#[test]
fn finding_untagged_content_shows_the_focused_accessibility_results() {
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
});
h.run_steps(3);
assert!(h.state_mut().execute("a11y.find_untagged"));
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Accessibility));
let report = &h.state().a11y.report.as_ref().unwrap().1;
let active: Vec<Rule> = report.results.iter().filter(|result| result.status != Status::Skipped).map(|result| result.rule).collect();
assert_eq!(active, [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields]);
}
+86
View File
@@ -735,6 +735,61 @@ fn the_panel_filters_and_sorts() {
assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added");
}
/// #1554: a page group's chevron folds that page's cards away and back, and the choice holds.
#[test]
fn page_groups_collapse_and_expand() {
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
for (page, text) in [(0, "First page note"), (1, "Second page note")] {
let shape = pdfcraft_engine::Shape::Note { at: [100.0, 100.0], icon: pdfcraft_engine::NoteIcon::Comment };
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: text.into(),
author: "Tester".into(),
}));
}
h.state_mut().views[0].comments.selected = None;
h.run_steps(3);
let shown = |h: &Harness<'static, PdfCraftApp>, text: &str| h.query_all_by_label_contains(text).count() > 0;
assert!(shown(&h, "First page note") && shown(&h, "Second page note"));
assert_eq!(h.query_all_by_label("Collapse").count(), 2, "one chevron per page group");
h.query_all_by_label("Collapse").next().unwrap().click();
h.run_steps(3);
assert!(!shown(&h, "First page note"), "page 1's card is folded away");
assert!(shown(&h, "Second page note"), "page 2 stays open");
h.run_steps(5);
assert!(!shown(&h, "First page note"), "the group stays folded on later frames");
h.get_by_label("Expand").click();
h.run_steps(3);
assert!(shown(&h, "First page note"), "page 1's card is back");
// Selecting a comment on a folded page opens its group so the card can be shown.
h.query_all_by_label("Collapse").next().unwrap().click();
h.run_steps(3);
assert!(!shown(&h, "First page note"));
h.state_mut().views[0].comments.selected = Some((0, 0));
h.state_mut().views[0].comments.reveal = true;
h.run_steps(3);
assert!(shown(&h, "First page note"), "the selected comment's group opened");
// "…" ▸ Collapse all pages, then Expand all pages.
h.state_mut().views[0].comments.selected = None;
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Collapse all pages").click();
h.run_steps(3);
assert!(!shown(&h, "First page note") && !shown(&h, "Second page note"), "every page is folded");
assert_eq!(h.query_all_by_label("Expand").count(), 2);
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Expand all pages").click();
h.run_steps(3);
assert!(shown(&h, "First page note") && shown(&h, "Second page note"), "every page is open again");
}
#[test]
fn comments_take_checkmarks_lock_hide_and_summarize() {
let mut h = harness(|app| {
@@ -1244,6 +1299,37 @@ fn hovering_a_comment_shows_its_author_and_text() {
assert!(h.query_by_label_contains("Check this figure").is_none(), "a dialog covers the page hover");
}
#[test]
fn preference_hides_author_only_highlight_hover_previews() {
// A highlight annotation with author Ada and empty contents over the first page.
let pdf = String::from_utf8_lossy(TEXT_FIXTURE).replace("/Contents 5 0 R /Resources", "/Contents 5 0 R /Annots [8 0 R] /Resources").replace(
"trailer",
"8 0 obj << /Type /Annot /Subtype /Highlight /Rect [50 50 150 120] /C [1 1 0] /T (Ada) /QuadPoints [50 120 150 120 50 50 150 50] >> endobj\ntrailer",
);
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("highlight_hover.pdf", None, pdf.into_bytes()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
app
});
h.run_steps(4);
settle(&mut h);
let p = at(&h, 100.0, 85.0);
// By default, author-only hover is shown.
h.hover_at(p);
h.run_steps(3);
h.get_by_label("Ada");
// Enabling preference hides author-only highlight hover.
h.state_mut().set_option("hide-empty-highlight-hover", "true").unwrap();
h.hover_at(p);
h.run_steps(3);
assert!(h.query_by_label("Ada").is_none(), "author-only hover must be suppressed");
}
#[test]
fn the_squiggly_tool_marks_selected_text() {
let mut h = harness(|_| {});
+227 -2
View File
@@ -7,11 +7,19 @@ use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
harness_bytes(include_bytes!("data/form.pdf").to_vec())
harness_with_step_dt(0.25)
}
fn harness_with_step_dt(step_dt: f32) -> Harness<'static, PdfCraftApp> {
harness_bytes_with_step_dt(include_bytes!("data/form.pdf").to_vec(), step_dt)
}
fn harness_bytes(bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
harness_bytes_with_step_dt(bytes, 0.25)
}
fn harness_bytes_with_step_dt(bytes: Vec<u8>, step_dt: f32) -> 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("form.pdf", None, bytes.clone()).unwrap();
@@ -68,6 +76,113 @@ fn texts(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
.collect()
}
#[test]
fn text_tool_drafts_grow_for_latin_and_arabic() {
for text in ["Wide words ".repeat(8), "سلام عليكم ".repeat(8)] {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let point = at(&h, 40.0, 300.0);
click(&mut h, point);
let before = h.state().views[0].content.draft.as_ref().unwrap().rect;
h.event(egui::Event::Text(text.clone()));
h.run_steps(4);
let draft = h.state().views[0].content.draft.as_ref().unwrap();
assert!(draft.rect[2] - draft.rect[0] > before[2] - before[0] + 10.0, "the editor grows before wrapping: {}", text);
assert!(draft.rect[2] <= 300.0 && draft.rect[1] < before[1], "the frame stays on the page and grows vertically");
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
let language = if text.starts_with("Wide") { "latin" } else { "arabic" };
h.render().unwrap().save(format!("{dir}/growing-{language}-text.png")).unwrap();
}
h.get_by_label("Done adding text").click();
h.run_steps(3);
if text.starts_with("سلام") && pdfcraft_fonts::document_arabic_font().is_none() {
assert!(h.state().views[0].content.draft.is_some(), "without a document font the draft remains available");
assert!(added(&h).is_empty());
continue;
}
let pdfcraft_engine::AddedContent::Text(saved) = &added(&h)[0].content else { panic!() };
assert_eq!(saved.text, text.trim_end());
assert!(saved.rect[2] - saved.rect[0] > before[2] - before[0] + 10.0, "Save uses the grown width");
}
}
#[test]
fn text_tool_single_click_retypes_added_text_without_another_item() {
for command in ["edit.text", "edit.edit_text"] {
for (original, replacement) in [("Original", "Replacement"), ("سلام", "سلام عليكم ورحمة الله")] {
if original == "سلام" && pdfcraft_fonts::document_arabic_font().is_none() {
continue;
}
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: original.into(), ..Default::default() },
}));
assert!(h.state_mut().execute(command));
h.run_steps(3);
let point = at(&h, 45.0, 195.0);
click(&mut h, point);
let draft = h.state().views[0].content.draft.as_ref().expect("one click edits the existing item");
assert_eq!(draft.index, Some(0));
assert_eq!(draft.text, original);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text(replacement.into()));
h.run_steps(3);
h.get_by_label("Done adding text").click();
h.run_steps(3);
assert_eq!(texts(&h), [replacement]);
let state = h.state();
let bytes = state.session.save_bytes(state.views[0].id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let id = reopened.open("again.pdf", None, bytes, None).unwrap();
assert_eq!(reopened.get(id).unwrap().added().len(), 1);
let pdfcraft_engine::AddedContent::Text(text) = &reopened.get(id).unwrap().added()[0].content else { panic!() };
assert_eq!(text.text, replacement);
}
}
}
#[test]
fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_them() {
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 220.0], text: "Original glyphs".into(), size: 18.0, ..Default::default() },
}));
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(3);
let point = at(&h, 45.0, 185.0);
click(&mut h, point);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.key_press(egui::Key::Backspace);
for _ in 0..100 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty());
let shot = h.render().unwrap();
for x in 105..140 {
for y in 185..197 {
let pixel = at(&h, x as f32, y as f32);
let [r, g, b, a] = shot.get_pixel(pixel.x as u32, pixel.y as u32).0;
assert!(r > 200 && g > 200 && b > 200 && a == 255, "old glyph ink remains at {x},{y}: {r},{g},{b},{a}");
}
}
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
shot.save(format!("{dir}/cleared-added-text.png")).unwrap();
}
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes, "previewing does not edit the document");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(texts(&h), ["Original glyphs"]);
assert!(h.state().views[0].content.draft.is_none());
}
#[test]
fn clicking_elsewhere_keeps_the_text_and_starts_a_new_box() {
// #74: a click elsewhere on the page threw away the text being typed and opened a new box.
@@ -236,6 +351,116 @@ fn typing_thai_text_keeps_marks_and_commits_to_page() {
assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]);
}
#[test]
fn finished_text_over_form_field_keeps_selection_and_drag_ownership() {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 60.0, 202.0);
click(&mut h, p);
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
assert_eq!(texts(&h), ["Reviewed"]);
let rect = added(&h)[0].content.rect();
let mid_x = ((rect[0] + rect[2]) / 2.0) as f32;
let mid_y = ((rect[1] + rect[3]) / 2.0) as f32;
assert!((20.0..280.0).contains(&mid_x) && (180.0..200.0).contains(&mid_y), "fixture text must overlap city: {rect:?}");
let center = at(&h, mid_x, 400.0 - mid_y);
click(&mut h, center);
assert!(h.state().views[0].forms.focus.is_none(), "selecting finished text must not focus the field underneath");
assert_eq!(h.state().views[0].content.selected, Some((0, 0)));
h.run_steps(60);
let start = at(&h, mid_x, 400.0 - mid_y);
let end = at(&h, mid_x, 400.0 - mid_y - 80.0);
h.drag_at(start);
h.run_steps(1);
for step in 1..=40 {
h.hover_at(start + (end - start) * (step as f32 / 40.0));
h.run_steps(1);
}
h.drop_at(end);
h.run_steps(4);
let moved = added(&h)[0].content.rect();
for (actual, expected) in moved.into_iter().zip([rect[0], rect[1] + 80.0, rect[2], rect[3] + 80.0]) {
assert!((actual - expected).abs() < 1.0, "text should move upward: {rect:?} -> {moved:?}");
}
assert!(h.state().views[0].forms.focus.is_none());
let bare = at(&h, 270.0, 210.0);
click(&mut h, bare);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
}
fn text_over_city() -> Harness<'static, PdfCraftApp> {
let mut h = harness_with_step_dt(1.0 / 60.0);
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 60.0, 202.0);
click(&mut h, p);
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
h
}
#[test]
fn finished_text_over_field_keeps_move_and_resize_cursors() {
let mut h = text_over_city();
let rect = added(&h)[0].content.rect();
let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32);
h.hover_at(center);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
// The upper-right handle overlaps the city field; it retains its resize gesture.
let corner = at(&h, rect[2] as f32, (400.0 - rect[3]) as f32);
h.hover_at(corner);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw);
let end = corner + egui::vec2(30.0, -20.0);
h.drag_at(corner);
h.run_steps(1);
for step in 1..=20 {
h.hover_at(corner + (end - corner) * (step as f32 / 20.0));
h.run_steps(1);
}
h.drop_at(end);
h.run_steps(4);
let resized = added(&h)[0].content.rect();
assert!(resized[2] > rect[2] + 1.0 && resized[3] > rect[3] + 1.0, "resize must retain ownership: {rect:?} -> {resized:?}");
assert!(h.state().views[0].forms.focus.is_none());
}
#[test]
fn finished_text_over_field_still_opens_its_editor() {
let mut h = text_over_city();
let rect = added(&h)[0].content.rect();
let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32);
h.run_steps(60);
h.hover_at(center);
h.run_steps(1);
for _ in 0..2 {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
}
h.run_steps(2);
assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "double click edits the added text");
assert!(h.state().views[0].forms.focus.is_none());
}
#[test]
fn closing_edit_panel_leaves_form_filling_available() {
let mut h = text_over_city();
h.state_mut().set_option("left", "closed").unwrap();
h.run_steps(3);
let p = at(&h, 100.0, 210.0);
click(&mut h, p);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
}
/// The screen rectangle of an added item's display-space box (y up, from the page's bottom).
fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect {
let s = h.state();
+83
View File
@@ -2626,6 +2626,89 @@ fn editing_existing_text_in_place() {
assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it");
}
#[test]
fn text_tool_clicking_source_text_edits_instead_of_adding_a_copy() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let blank = egui::pos2(page.left() + 0.2 * page.width(), page.top() + 0.1 * page.height());
h.hover_at(blank);
h.run_steps(1);
h.drag_at(blank);
h.run_steps(1);
h.drop_at(blank);
h.run_steps(3);
h.event(egui::Event::Text("Keep this note".into()));
h.run_steps(3);
let point = egui::pos2(page.left() + 40.0 / 200.0 * page.width(), page.top() + 142.0 / 300.0 * page.height());
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(4);
assert!(h.state().views[0].content.draft.is_none(), "clicking source text must not start an added layer");
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Page 1");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1, "the previous draft is kept");
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.event(egui::Event::Text("Replacement".into()));
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Keep this note\nReplacement"]);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().added().len(), 1, "editing the source adds no second layer");
}
#[test]
fn text_tool_mixed_style_row_grows_in_the_editor_and_saved_pdf() {
let bytes = source_font_fixture_with_text("BT /F1 12 Tf 20 220 Td (Small ) Tj /F2 12 Tf (row) Tj ET");
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-row.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let point = egui::pos2(page.left() + 25.0 / 300.0 * page.width(), page.top() + 75.0 / 300.0 * page.height());
h.hover_at(point);
h.run_steps(1);
h.drag_at(point);
h.run_steps(1);
h.drop_at(point);
h.run_steps(4);
let replacement = "Small row with more words";
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" with more words".into()));
h.run_steps(4);
let galley = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == replacement => Some(&t.galley),
_ => None,
})
.expect("the draft is painted");
assert_eq!(galley.rows.len(), 1, "mixed source runs grow as one visual row");
if let Ok(path) = std::env::var("PDFCRAFT_MIXED_ROW_SHOT") {
h.render().unwrap().save(path).unwrap();
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(3);
let state = h.state();
let doc = state.session.get(state.views[0].id).unwrap();
assert_eq!(doc.text_blocks(0)[0].editor_text, replacement);
assert_eq!(doc.text_blocks(0)[0].rows.len(), 1);
let bytes = state.session.save_bytes(state.views[0].id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let id = reopened.open("again.pdf", None, bytes, None).unwrap();
assert_eq!(reopened.get(id).unwrap().text_blocks(0)[0].rows.len(), 1);
}
/// One page whose only line is drawn twice at the same spot (fake bold, as many generated
/// documents do).
fn double_drawn() -> Vec<u8> {
+71
View File
@@ -872,6 +872,77 @@ fn the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced() {
assert_eq!(h.query_all_by_label("orphan").count(), 1);
}
#[test]
fn list_box_clicks_select_directly_and_ctrl_click_toggles_multi() {
let bytes = pdf_from_objects(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListOne) /Opt [(Red) (Green) (Blue)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListMulti) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 100 200 170] /P 3 0 R /F 4 >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
]);
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("lists.pdf", None, bytes).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(4);
// Click row 1 (Green) in ListOne
let r_one = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "ListOne").unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
};
let zoom_one = r_one.height() / 70.0;
let p_green = egui::pos2(r_one.center().x, r_one.min.y + 19.0 * zoom_one);
h.hover_at(p_green);
h.run_steps(1);
h.drag_at(p_green);
h.run_steps(1);
h.drop_at(p_green);
h.run_steps(3);
assert_eq!(value(&h, "ListOne"), ["Green"], "clicking row 1 in ListOne selected Green");
// Click row 1 (Tue) in ListMulti
let r_multi = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "ListMulti").unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
};
let zoom_multi = r_multi.height() / 70.0;
let p_tue = egui::pos2(r_multi.center().x, r_multi.min.y + 19.0 * zoom_multi);
h.hover_at(p_tue);
h.run_steps(1);
h.drag_at(p_tue);
h.run_steps(1);
h.drop_at(p_tue);
h.run_steps(3);
assert_eq!(value(&h, "ListMulti"), ["Tue"], "clicking row 1 in ListMulti selected Tue");
// Ctrl/Cmd-click row 3 (Thu) in ListMulti
let p_thu = egui::pos2(r_multi.center().x, r_multi.min.y + 42.0 * zoom_multi);
h.hover_at(p_thu);
h.run_steps(1);
h.event(egui::Event::PointerMoved(p_thu));
h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND));
h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::COMMAND });
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(3);
assert_eq!(value(&h, "ListMulti"), ["Tue", "Thu"], "Ctrl-clicking Thu toggled it on, keeping Tue");
}
/// Auto-size (`0 Tf`, inherited from the AcroForm) single-line and multiline fields, and a fixed 10 pt one.
fn auto_size_form() -> Vec<u8> {
pdf_from_objects(&[
+91 -1
View File
@@ -1297,12 +1297,17 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() {
/// A 40 × 30 picture (one red corner) and a harness showing a PDF of it.
fn picture_harness() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) {
picture_harness_sized(egui::vec2(1400.0, 900.0))
}
/// [`picture_harness`] in a window of this size.
fn picture_harness_sized(size: egui::Vec2) -> (image::RgbaImage, Harness<'static, PdfCraftApp>) {
let mut px = image::RgbaImage::from_pixel(40, 30, image::Rgba([10, 200, 30, 255]));
px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap();
let pdf = pdfcraft_engine::Session::new().create_from_images(&[("p.png".into(), png.into_inner())]).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
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("pic.pdf", None, pdf.to_vec()).unwrap();
@@ -1417,6 +1422,91 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() {
let png = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap().into_rgba8();
assert_eq!(png.into_raw(), vec![255, 0, 0, 255, 0, 0, 255, 255]);
}
/// A picture in Edit text & images, right-clicked so its menu is open (in a tall window, so the
/// whole menu is on screen).
fn picture_menu() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) {
let (px, mut h) = picture_harness_sized(egui::vec2(1400.0, 1500.0));
h.state_mut().set_option("quick", "edit-text").unwrap();
h.run_steps(3);
let i = h.state().active.unwrap();
let at = h.state().views[i].page_screen_rect(0).expect("on screen").center();
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
(px, h)
}
fn picture_count(h: &Harness<'static, PdfCraftApp>) -> usize {
let s = h.state();
s.session.get(s.active_ids().unwrap().1).unwrap().page_images(0).len()
}
#[test]
fn the_picture_menu_cuts_a_picture_to_the_clipboard_and_removes_it() {
let (px, mut h) = picture_menu();
assert_eq!(picture_count(&h), 1);
h.get_by_label("Cut Image").click();
h.run_steps(3);
let (w, hgt, rgba) = h.state().last_copied_image.clone().expect("the image was copied first");
assert_eq!((w, hgt), (40, 30));
assert_eq!(rgba, px.into_raw());
assert_eq!(picture_count(&h), 0, "then taken off the page");
h.state_mut().undo();
h.run_steps(2);
assert_eq!(picture_count(&h), 1, "one undo step puts it back");
}
#[test]
fn the_picture_menu_marks_a_picture_for_redaction() {
let (_, mut h) = picture_menu();
let marks = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.active_ids().unwrap().1).unwrap().redaction_marks()
};
assert_eq!(marks(&h), 0);
h.get_by_label("Redact Image").click();
h.run_steps(3);
assert_eq!(marks(&h), 1);
assert_eq!(picture_count(&h), 1, "marked, not yet removed: Redact a PDF applies it");
}
#[test]
fn the_picture_menu_opens_link_properties_and_recognize_text_for_the_picture() {
let (_, mut h) = picture_menu();
h.get_by_label("Create Link…").click();
h.run_steps(3);
assert!(matches!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::LinkProps)));
let draft = h.state().link_draft.clone().expect("the link draft");
assert_eq!(draft.page, 0);
assert!(draft.rect[2] > draft.rect[0] && draft.rect[3] > draft.rect[1], "over the picture: {:?}", draft.rect);
let (_, mut h) = picture_menu();
h.get_by_label("Recognize Text…").click();
h.run_steps(3);
assert!(matches!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::RecognizeText)));
let d = &h.state().ocr_draft;
assert_eq!((format!("{:?}", d.pages), d.from, d.to), ("Range".to_string(), 1, 1));
}
/// The menu is longer than the picture is tall, so most items lie outside it: the pointer moving
/// onto them must not close the menu (it used to be drawn only while the pointer was over the
/// picture, so Delete and the items below the page could not be clicked).
#[test]
fn every_item_of_the_picture_menu_can_be_clicked_outside_the_picture() {
let (_, mut h) = picture_menu();
let s = h.state();
let page = s.views[s.active.unwrap()].page_screen_rect(0).expect("on screen");
let delete = h.get_by_label("Delete").rect();
assert!(delete.center().y > page.bottom(), "Delete lies below the page: {delete:?} vs {page:?}");
assert_eq!(picture_count(&h), 1);
h.get_by_label("Delete").click();
h.run_steps(3);
assert_eq!(picture_count(&h), 0);
}
#[test]
fn mouse_wheel_scrolls_while_selecting_text() {
let mut h = harness();
+1 -1
View File
@@ -13,7 +13,7 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
circle, polygon, polyline, markup, stamp, caret, ink, attachments, sound, redact) with rect,
name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink,
default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads
(`inreplyto`); field values nested by name.
(`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions.
- **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects.
- **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and
tab-delimited text (a row of names, a row of values).
+30 -5
View File
@@ -558,10 +558,30 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V
let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n");
out
}
/// The XML element name for a field name (letters, digits, `_`, `-`, `.`).
/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName.
/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar
fn xml_name_start(c: char) -> bool {
matches!(
c,
'_' | 'A'..='Z' | 'a'..='z'
| '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}'
| '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}'
| '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}'
| '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}'
| '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}'
| '\u{10000}'..='\u{EFFFF}'
)
}
fn xml_name_char(c: char) -> bool {
xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}')
}
/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames.
fn xml_name(name: &str) -> String {
let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect();
if !s.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') {
let mut s: String =
name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect();
if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') {
s.insert(0, '_');
}
s
@@ -592,7 +612,7 @@ pub fn export_data(doc: &Document, format: Format) -> String {
out
}
Format::Csv => {
let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
format!(
"{}\n{}\n",
fields.iter().map(|f| q(&f.0)).collect::<Vec<_>>().join(","),
@@ -925,7 +945,12 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec<Str
for f in node.children().filter(|c| c.has_tag_name("field")) {
let Some(name) = f.attribute("name") else { continue };
let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") };
let vals: Vec<String> = f.children().filter(|c| c.has_tag_name("value")).map(|v| v.text().unwrap_or("").to_string()).collect();
let vals: Vec<String> = f
.children()
.filter(|c| c.has_tag_name("value"))
// Comments and processing instructions can split a value into several text nodes.
.map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect())
.collect();
if !vals.is_empty() {
out.push((full.clone(), vals));
}
+124
View File
@@ -292,6 +292,60 @@ trailer\n<< /Root 1 0 R >>\n%%EOF\n";
assert_eq!((parent("cycle-a").as_deref(), parent("cycle-b").as_deref()), (Some("cycle-b"), Some("cycle-a")));
}
#[test]
fn xml_field_names_remain_valid_and_round_trip_without_collisions() {
assert_eq!(xml_name(".Value"), "_.Value");
let names = [
("Area²", "Area_"),
("Area_", "Area_"),
("Aµ", "A_"),
("µRate", "_Rate"),
("²Rate", "_Rate"),
("½Amount", "_Amount"),
("1Rate", "_1Rate"),
("Café", "Café"),
("Δείγμα", "Δείγμα"),
("姓名", "姓名"),
("٣Value", "_٣Value"),
("\u{345}Rate", "_\u{345}Rate"),
("A\u{345}", "A\u{345}"),
("Alpha_1-2", "Alpha_1-2"),
("ns:value", "ns_value"),
("-Value", "_-Value"),
];
let mut src = one_field();
pdfcraft_forms::delete_field(&mut src, "Name").unwrap();
for (index, (name, _)) in names.iter().enumerate() {
add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some(name)).unwrap();
set_value(&mut src, name, &FieldValue::Text(format!("value-{index}"))).unwrap();
}
let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap());
let src = Document::open(source.clone()).unwrap();
let want = values(&src);
for format in [Format::Xml, Format::Xdp] {
let text = export_data(&src, format);
let xml = roxmltree::Document::parse(&text).unwrap();
let children: Vec<_> = xml.root_element().children().filter(|n| n.is_element()).collect();
assert_eq!(children.len(), names.len());
for (child, (original, tag)) in children.iter().zip(names) {
assert_eq!(child.tag_name().name(), tag);
assert_eq!(child.attribute(("http://ns.adobe.com/xfdf-transition/", "original")), (original != tag).then_some(original));
}
assert!(!src.is_modified(), "export must not change the source PDF");
assert_eq!(src.bytes().as_slice(), source.as_slice());
let mut dst = Document::open(source.clone()).unwrap();
for (name, _) in names {
set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap();
}
let report = import(&mut dst, text.as_bytes()).unwrap();
assert_eq!(report.fields, names.len());
assert!(report.rejected.is_empty(), "{report:?}");
let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(values(&reopened), want, "{format:?}: sanitized names must preserve distinct original fields");
}
}
#[test]
fn form_data_as_xml_csv_and_text() {
let src = filled();
@@ -346,6 +400,41 @@ fn one_field() -> Document {
Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
}
#[test]
fn csv_exports_preserve_carriage_returns_in_names_and_values() {
for (name, value, expected) in [
("Name", "plain", "Name\nplain\n"),
("Name", "Tail\r", "Name\n\"Tail\r\"\n"),
("Name", "Alpha\rBeta", "Name\n\"Alpha\rBeta\"\n"),
("Name", "\rLead", "Name\n\"\rLead\"\n"),
("Name", "\r", "Name\n\"\r\"\n"),
("Name", "Alpha\r\nBeta", "Name\n\"Alpha\r\nBeta\"\n"),
("Name", "Alpha\nBeta", "Name\n\"Alpha\nBeta\"\n"),
("Name", "comma,\r", "Name\n\"comma,\r\"\n"),
("Name", "\"quoted\"\r", "Name\n\"\"\"quoted\"\"\r\"\n"),
("Name", "before\r,\"after\"", "Name\n\"before\r,\"\"after\"\"\"\n"),
("Na\rme", "both\r", "\"Na\rme\"\n\"both\r\"\n"),
] {
let mut src = one_field();
let field = fields(&src)[0].obj;
src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap();
set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap();
let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap());
let src = Document::open(source.clone()).unwrap();
assert_eq!(values(&src), [(name.to_string(), vec![value.to_string()])]);
let csv = export_data(&src, Format::Csv);
let mut dst = Document::open(source).unwrap();
set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap();
let report = import(&mut dst, csv.as_bytes()).unwrap();
assert_eq!(report.fields, 1);
assert!(report.rejected.is_empty(), "{report:?}");
let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(values(&reopened), [(name.to_string(), vec![value.to_string()])]);
assert_eq!(csv, expected, "CSV must quote CR as well as LF, commas and quotes");
}
}
/// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the
/// value used to split it and only the text before the comma was imported.
#[test]
@@ -887,3 +976,38 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() {
assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}");
}
}
// XML comments and processing instructions are not field text (XML 1.0 §§2.5–2.6).
fn split_text_values() -> [(&'static str, &'static str); 5] {
[
("Ada<!--editor note--> Lovelace", "Ada Lovelace"),
("<!--editor note-->Ada", "Ada"),
("Ada<?editor keep?> Lovelace", "Ada Lovelace"),
(" Å<!--note--><![CDATA[<&>]]><?editor keep?>&#x03A9; ", " Å<&>Ω "),
("<!--empty--><?editor keep?>", ""),
]
}
#[test]
fn xfdf_data_values_join_text_around_xml_comments_and_instructions() {
for (xml_text, expected) in split_text_values() {
let xml = format!(r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><fields><field name="Name"><value>{xml_text}</value></field></fields></xfdf>"#);
assert_eq!(data_values(xml.as_bytes()).unwrap(), vec![("Name".into(), vec![expected.to_string()])], "{xml_text}");
}
}
#[test]
fn xfdf_import_keeps_text_around_xml_comments_and_instructions() {
for (xml_text, expected) in split_text_values() {
let xml = format!(r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><fields><field name="Name"><value>{xml_text}</value></field></fields></xfdf>"#);
let mut doc = one_field();
set_value(&mut doc, "Name", &FieldValue::Text("old value".into())).unwrap();
let report = import(&mut doc, xml.as_bytes()).unwrap();
assert_eq!(report.fields, 1);
assert!(report.rejected.is_empty(), "{report:?}");
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(saved)).unwrap();
let want: Vec<&str> = if expected.is_empty() { vec![] } else { vec![expected] };
assert_eq!(values(&reopened)[0].1, want, "{xml_text}");
}
}
+29 -2
View File
@@ -50,7 +50,7 @@ do not contact a desktop session bus. Page and document colours retain their exi
## Logs
The desktop app writes its `log` records to standard error and to `logs/pdfcraft.log` in its
settings folder, next to `app.ron`: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or
data folder: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or
`$XDG_DATA_HOME/pdfcraft/logs/`), macOS `~/Library/Application Support/PdfCraft/logs/`, Windows
`%APPDATA%\PdfCraft\data\logs\`. In portable mode (a `portable.txt` file next to the executable, as
the Windows portable zip ships) the settings folder is `PdfCraftData\` beside the executable, and
@@ -105,13 +105,26 @@ reach this retry instead of the uncaptured-error panic handler. This does not fi
driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disables the retry.
`PDFCRAFT_RENDERER` skips it or chooses OpenGL from the start.
## Preferences
Linux and FreeBSD store `app.ron` in `$XDG_CONFIG_HOME/pdfcraft/`, or
`~/.config/pdfcraft/` when that variable is unset, empty or relative. This follows the
[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/).
On first launch after upgrading, existing `app.ron` preferences are copied from the old
data folder without replacing an existing configuration file. The old copy is retained;
logs, crash recovery and digital IDs stay in their data locations. If migration fails,
the app logs the failure and continues using the original preferences folder.
The existing PrintCraft-to-PdfCraft migration runs first. macOS, Windows and portable
copies retain their current preferences locations.
## Environment variables
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) |
| `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error |
| `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery |
| `XDG_CONFIG_HOME` | Linux/FreeBSD: absolute configuration base for `pdfcraft/app.ron`; defaults to `~/.config` |
| `XDG_DATA_HOME` | Linux/FreeBSD: base of the log folder and crash recovery; previous preferences are migrated from here |
| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen |
| `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL |
| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. PdfCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device at start-up |
@@ -120,3 +133,17 @@ driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disable
| `PDFCRAFT_BUILD_LABEL` | Build time: a label for a local or patched build (letters, digits, inner dots), shown after the version in `--version`, About and the log (`0.7.1 (titlebar)`). `packaging/linux/package.sh` also puts it in the file names and the .rpm/.deb release (`1.titlebar`), which upgrades over the release of the same version |
| `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) |
| `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) |
| `FONTCONFIG_FILE` | On Linux/Unix, the Fontconfig configuration file used to discover installed UI fallback fonts (default: `/etc/fonts/fonts.conf`); relative filenames are resolved under `FONTCONFIG_PATH` |
| `FONTCONFIG_PATH` | Directory for a relative `FONTCONFIG_FILE` (default: `/etc/fonts`; the first entry of a search path is used) |
On Linux/Unix, the UI's existing fallback filenames are also searched in Fontconfig's configured
directories and their subdirectories. This finds fonts in Nix store paths and user font directories
without invoking `fc-match` or linking libfontconfig. The scan is cached and bounded; missing or
malformed configuration leaves the ordinary `/usr/share/fonts` and `/usr/local/share/fonts`
candidates available. Font size and glyph-coverage checks still apply. This only reads installed
fonts for the interface; it does not embed them into PDFs or packages.
Relative includes resolve beside the config that contains them. Included directories load only
digit-prefixed `*.conf` files, in lexical order; backups and disabled files are ignored. The scan
limits each config to 1 MiB, visits at most 256 config files/directories, and bounds include depth
to 16 and directory entries to 32,768. Cyclic includes are skipped.
+6
View File
@@ -10,6 +10,12 @@ moved because an earlier row wrapped, are rewritten, and formatting a selection
only the rows it covers. Changing the whole paragraph's formatting, width or position rewrites
every row.
Add Text also opens existing text when it is clicked; a blank-space click starts a new item.
Both text tools retype an added text item with one click. Draft boxes grow to the page edge
before wrapping, and the saved item uses that width. A single visual source row grows across font/style boundaries;
multi-row paragraphs retain their existing wrap width. Retyping uses a preview with only
that added item's old drawing removed, so deleted draft characters do not show underneath.
A subset font contains only some of the original face's glyphs. A new character cannot be
reconstructed from a glyph the PDF does not contain. **Load font…** imports an openly licensed,
embeddable standalone `.ttf` or `.otf` face. **Installed fonts…** discovers eligible user-installed
+4 -2
View File
@@ -198,8 +198,10 @@ update the numbers.
### 11. Accessibility: only text-only automatic structure creation
- **Missing:** auto-tag mixed text, images, tables, lists, links and fields; connect preview heading
proposals to Apply; visual Order and Content panels, Reading Order tool, table editor, Make Accessible action,
- **Missing:** apply auto-tag proposals to marked content and a valid parent tree, tag fields
into structure trees with unusual layouts (only a direct Document root with a direct
ParentTree Nums array is extended),
visual Tags, Order and Content panels, Reading Order tool, table editor, Make Accessible action,
Read Out Loud; full keyboard operation of PdfCraft itself and a screen-reader audit.
- **Evidence:** all 32 checker rules, figure alt text, tag property and sibling order edits, and
a bounded auto-tag preview exist. Apply writes valid structure and parent-tree links for
+10 -4
View File
@@ -117,12 +117,12 @@ total matches the parity estimate.
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
@@ -133,7 +133,8 @@ total matches the parity estimate.
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |
@@ -180,6 +181,11 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | M4/#2052 candidate: Pages sidebar select-all, focus ownership, Escape and document isolation validated; estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
| 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. |
| 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. |
| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. |
| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. |
| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. |
+23 -23
View File
@@ -3553,8 +3553,8 @@ milestone = "M3"
status = "shipped"
commands = ["comment.list"]
tools = ["comment_list"]
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"]
notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box"
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::page_groups_collapse_and_expand"]
notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet"
[[feature]]
id = "comment.appearance-generation"
@@ -4687,8 +4687,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.import_data"]
tools = ["doc_import_data"]
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
notes = "XFDF, FDF; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)"
evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
notes = "XFDF, FDF; XFDF text survives XML comments and processing instructions; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)"
[[feature]]
id = "form.export-data"
@@ -8876,11 +8876,11 @@ title = "Autotag form fields"
area = "M"
tier = "P1"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["a11y.autotag_fields"]
tools = ["accessibility_autotag_form_fields"]
evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"]
notes = "For an untagged AcroForm, writes Form structure elements, OBJR references, widget StructParent keys and ParentTree entries. An existing tree is extended when its root points directly to a Document element and its ParentTree uses a direct Nums array; other layouts are refused before any edit. A widget listed on several pages is tagged once. Text, figures, links and other content remain untagged."
[[feature]]
id = "a11y.reading-order-tool"
@@ -8960,11 +8960,11 @@ title = "Content panel"
area = "M"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["a11y.tags"]
tools = ["accessibility_tag_content"]
evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"]
notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned."
[[feature]]
id = "a11y.create-tag-from-selection"
@@ -8996,11 +8996,11 @@ title = "Find unmarked content, comments and links"
area = "M"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["a11y.find_untagged"]
tools = ["accessibility_find_untagged"]
evidence = ["crates/automation/tests/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"]
notes = "Lists findings for untagged painted page content, non-widget annotations and form widgets, optionally on selected pages. It reuses the checker and does not identify untagged table structure, links already represented by an annotation, or structure-tree elements that lack content."
[[feature]]
id = "a11y.table-editor"
@@ -9119,8 +9119,8 @@ milestone = "M12"
status = "shipped"
commands = []
tools = ["view_reading_colors"]
evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"]
notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)."
evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"]
notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)."
[[feature]]
id = "a11y.reflow-tagged"
@@ -9807,8 +9807,8 @@ milestone = "M14"
status = "partial"
commands = ["app.preferences"]
tools = ["js_enabled"]
evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override"]
notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet."
evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data"]
notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet. XDG desktops store app.ron under XDG_CONFIG_HOME/pdfcraft or ~/.config/pdfcraft; existing data-folder preferences are copied without replacing configured preferences, with fallback on migration errors. Logs and recovery retain data locations. Portable/non-XDG locations are unchanged."
[[feature]]
id = "misc.prefs-general"
+7 -7
View File
@@ -9,13 +9,13 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing
| 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`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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 for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
| 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); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid | (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_soLine truncated
| 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); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid | (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`; (7) `raster::tests::pixels_are_a_plain_rendersLine truncated
| 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); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster:Line truncated
| 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); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::testsLine truncated
| 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); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (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; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` |
| 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), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
| 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; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependencyLine truncated
@@ -24,7 +24,7 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing
| 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; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (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`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` |
| 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), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
| 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; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (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`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retriLine truncated
| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, Line truncated
| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, Line truncated
| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure |
| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate |
+7
View File
@@ -66,6 +66,13 @@ pub trait Device<'a> {
fn actual_text(&mut self, _text: &[u8]) {}
/// Called at the end of a marked content sequence (EMC).
fn end_marked_content(&mut self) {}
/// PdfCraft patch (29): the page's annotations are about to be drawn (until
/// `end_annotations`), as page content finishes. Devices can use this to treat their
/// paints as annotation colours rather than document colours — the reading-colours
/// setting replaces document colours only, never the colours a comment was given.
fn begin_annotations(&mut self) {}
/// PdfCraft patch (29): the page's annotations are done; what follows is page content.
fn end_annotations(&mut self) {}
}
/// A device that discards all drawing operations.
+4
View File
@@ -175,6 +175,9 @@ pub fn interpret_page<'a>(
if context.settings.render_annotations
&& let Some(annot_arr) = page.raw().get::<Array<'_>>(ANNOTS)
{
// PdfCraft patch (29): bracket the annotations so devices can tell their paints from
// the document's own (the reading-colours setting recolours the document, not comments).
device.begin_annotations();
for annot in annot_arr.iter::<Dict<'_>>() {
let flags = annot.get::<u32>(F).unwrap_or(0);
@@ -275,6 +278,7 @@ pub fn interpret_page<'a>(
draw_highlight_without_appearance(&annot, context, device);
}
}
device.end_annotations();
}
}
+11 -3
View File
@@ -254,6 +254,16 @@ pub fn render_into<'a>(
interpreter_settings.preview,
);
// PdfCraft patch (18): the page renders as one transparency group with the background
// painted *inside* it, as the initial backdrop. A group isolates what it wraps from what
// was painted before it, so with the background outside, a Multiply highlight blended
// only with the group's transparent backdrop and stayed its own colour — over a dark
// reading-colours background the whole quad became one flat bar and the words under it
// vanished. Inside the group the highlight multiplies with the background too: the words
// keep the highlight's colour and the band between them takes the background (white
// backgrounds are unaffected — multiplying with white is the identity).
device.push_transparency_group(1.0, None, BlendMode::Normal);
device.ctx.set_paint(render_settings.bg_color);
device
.ctx
@@ -265,12 +275,10 @@ pub fn render_into<'a>(
fill: FillRule::NonZero,
});
device.push_transparency_group(1.0, None, BlendMode::Normal);
interpret_page(page, &mut state, &mut device);
device.pop_transparency_group();
device.pop_clip_path();
device.pop_transparency_group();
let mut resources = vello_cpu::Resources::default();
device.ctx.render_to_buffer(
+23
View File
@@ -432,6 +432,9 @@ pub(crate) struct Renderer {
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped
/// while rendering (the "reading colours" view setting).
color_replacement: Option<ColorReplacement>,
/// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their
/// colours — reading colours replace document colours, never annotation colours.
in_annotations: bool,
pub(crate) inside_pattern: bool,
/// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one
/// render, every nested renderer starting empty) and so to one `preview` setting: a mask
@@ -466,6 +469,7 @@ impl Renderer {
Self {
ctx: RenderContext::new_with(width, height, settings),
color_replacement,
in_annotations: false,
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: cache.outline_cache.clone(),
@@ -573,6 +577,7 @@ impl Renderer {
// PdfCraft patch: this renders an image's alpha mask, which must keep its
// original values, so it is never remapped by the reading-colours setting.
color_replacement: None,
in_annotations: false,
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
@@ -1023,6 +1028,11 @@ impl Renderer {
let Some(replacement) = self.color_replacement else {
return paint.clone();
};
// PdfCraft patch (17): annotations keep the colours they were given; reading colours
// replace document colours only (issue #1548).
if self.in_annotations {
return paint.clone();
}
match paint {
Paint::Color(c) => {
let mapped = replacement.map(c.to_rgba().to_rgba8(), is_text);
@@ -1154,6 +1164,7 @@ impl Renderer {
// PdfCraft patch: a pattern cell is painted as one artwork, so its
// content is never remapped by the reading-colours setting.
color_replacement: None,
in_annotations: false,
cur_mask: None,
inside_pattern: true,
soft_mask_cache: HashMap::default(),
@@ -1469,6 +1480,7 @@ impl<'a> Device<'a> for Renderer {
// coverage values, so its content is never remapped by
// the reading-colours setting.
color_replacement: None,
in_annotations: false,
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
@@ -1613,6 +1625,16 @@ impl<'a> Device<'a> for Renderer {
}
}
/// PdfCraft patch (17): the reading-colours mapping is suspended for annotations.
fn begin_annotations(&mut self) {
self.in_annotations = true;
}
/// PdfCraft patch (17): back to document content; reading colours apply again.
fn end_annotations(&mut self) {
self.in_annotations = false;
}
fn draw_rect(
&mut self,
rect: &Rect,
@@ -1754,6 +1776,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei
// PdfCraft patch: a soft mask must keep its original luminance values, so its
// content is never remapped by the reading-colours setting.
color_replacement: None,
in_annotations: false,
inside_pattern: false,
cur_mask: None,
soft_mask_cache: HashMap::default(),
@@ -34,6 +34,7 @@ use crate::platform_impl::wayland::state::WinitState;
use crate::platform_impl::wayland::{self, DeviceId, WindowId};
pub mod relative_pointer;
mod scroll;
impl PointerHandler for WinitState {
fn pointer_frame(
@@ -169,26 +170,20 @@ impl PointerHandler for WinitState {
window_id,
);
},
PointerEventKind::Axis { horizontal, vertical, .. } => {
PointerEventKind::Axis { horizontal, vertical, source, .. } => {
// Get the current phase.
let mut pointer_data = pointer.winit_data().inner.lock().unwrap();
let has_discrete_scroll = horizontal.discrete != 0 || vertical.discrete != 0;
// Figure out what to do about start/ended phases here.
//
// Figure out how to deal with `Started`. Also the `Ended` is not guaranteed
// to be sent for mouse wheels.
let phase = if horizontal.stop || vertical.stop {
TouchPhase::Ended
} else {
match pointer_data.phase {
// Discrete scroll only results in moved events.
_ if has_discrete_scroll => TouchPhase::Moved,
TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved,
_ => TouchPhase::Started,
}
};
// PdfCraft patch (13, #991): use the source as well as discrete steps;
// pixel-only wheels do not promise an Ended event either.
let phase = scroll::phase(
pointer_data.phase,
source,
has_discrete_scroll,
horizontal.stop || vertical.stop,
);
// Update the phase.
pointer_data.phase = phase;
@@ -0,0 +1,21 @@
//! PdfCraft patch (13): Wayland scroll-phase policy, shared with the desktop regression tests.
use wayland_client::protocol::wl_pointer::AxisSource;
use crate::event::TouchPhase;
pub(crate) fn phase(previous: TouchPhase, source: Option<AxisSource>, has_discrete_scroll: bool, stopped: bool) -> TouchPhase {
// Wayland does not guarantee axis_stop for these sources. A pixel-only wheel must
// not start a finger gesture: consumers would retain its modifiers indefinitely.
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_pointer-event-axis_source
if stopped {
TouchPhase::Ended
} else if has_discrete_scroll || matches!(source, Some(AxisSource::Wheel | AxisSource::WheelTilt | AxisSource::Continuous)) {
TouchPhase::Moved
} else {
match previous {
TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved,
_ => TouchPhase::Started,
}
}
}