M12: retain product notes while integrating current main

This commit is contained in:
PerchAnton
2026-10-18 18:11:02 +03:00
44 changed files with 703 additions and 94 deletions
+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 = "a3234684d5318ce38213c79533d3eebb9c635497ba9e00fb9ca7ebe2fad9d73d"
sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
# 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 = "c5e0206846b009bcb366d092c3d64fd8b9e42d29d5406fb73ae36ecb1f2083d5"
sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
[[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 = "349a2ead371bdbfeefb9ab2279c676b205cefe163cbef174f8123fe4bf739ab9"
sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
[[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 = "cb332907792d4aa760ca9862160e24a7b469afdbcfb93235e99deb8d58604ae2"
sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
[[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 = "ebd3a12e471ba9d9a69e6532fbac2d027dec3c91b79cdfbb1c6c386d13de3bd6"
sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
[[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 = "f5e0553165ab0223bfbc809b1630170d1af11d6546ae5a682d4090104b6e4a05"
sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
[[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 = "dd2171a0a940a84e512fd6408a11cddf104be897ec4ab670c5ef6f1781ccb671"
sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
[[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 = "1b9b3d0d6dab626c3acb8c9aa64d64e56b98eb3f2faf71dcb1957067e72706db"
sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
[[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 = "668f23f4095120343e3f0910dc8f5844765370fae40400c48d685cad20dcf60e"
sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
[[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 = "02f3fd4b2721b0ea9c01c99d477a0339a28180514568179d3fc420e7fb81c671"
sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
[[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 = "26eb438c309d34e55eba959a21f139db91fa5fd1d6d4b7c3d1cd0fa79a60a5b0"
sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
[[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 = "c04b6d0320bce999b33bd189bffcec8a196d4169e56cc65675dc920c5a671dac"
sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
[[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 = "37b042977d934b0b550e5f52faa5d90abbcb9e7ff61ff9588021610ce709e474"
sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
[[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 = "3d1f1d2558eec1271fa653bc38616feeefc6559deeb2a0cdbf07d67634e9ac97"
sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
[[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 = "03071249bd6c13f79da72262415e484fb1aa36098a37a49094b9dab58cebc4e2"
sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
[[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 = "c0ba73b27b95b275f5eebc040ab1c3c6c3b227c63e1c637b4cdb27d0a9264a8b"
sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
[[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 = "e3af76714ee9899328a453ec83195f4dc462fe48ba445fbe3688c777e74bc9dc"
sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
[[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 = "0855d7f785c433799da83400ebc932f987eb543008486bc4f553aadb4102c2ba"
sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
[[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 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b"
sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
[[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 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538"
sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
[[asset]]
@@ -3352,7 +3352,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 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34"
sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -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 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89"
sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
[[asset]]
+11
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2572: repeated XFA print buttons dispatch to their shared click script; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
@@ -165,6 +167,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
- **2026-10-15 (M12, #2572):** Every copy of a page-master button dispatches to its XFA click script by the clicked field's `/PCSom`, including early copies that share a SOM with later pages. Synthetic engine and automation regressions cover the print script using `numPages`, all three copies and save/reopen. XFA remains partial; estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
@@ -510,8 +518,11 @@ Newest first: the date, what moved, and the new overall estimate.
## Revision history
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-15 | minor | M12/#2572: repeated page-master buttons use the clicked field's SOM for XFA script dispatch; synthetic engine/tool regressions, including save/reopen. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
+32 -16
View File
@@ -298,14 +298,21 @@ fn info(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
let pdf_pages = info.pages.len();
let acroform_fields = info.fields.len();
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
.then(|| {
let mut s = pdfcraft_engine::Session::new();
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
let d = s.get(id)?;
d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }))
d.xfa.as_ref().map(|x| {
info = d.info.clone();
// The in-memory layout is an appended revision, not the file being inspected.
info.file_size = bytes.len();
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
})
})
.flatten();
let json = serde_json::json!({
@@ -313,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
"pdf_version": info.pdf_version,
"file_size": info.file_size,
"pages": info.pages.len(),
"pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
@@ -348,7 +356,19 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
}
let password = flag(args, "--password");
let bytes = read(path)?;
let mut bytes = read(path)?;
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
// permissions password, a document that doesn't allow copying gives no text.
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
// The engine's hint is about editing in the app; on the command line the way in is
// `--password`.
doc.check_copying()
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
if doc.xfa.is_some() {
bytes = doc.display.clone();
}
}
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
// A document the renderer could not open (wrong or missing password, unparseable file) has
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
@@ -359,15 +379,6 @@ fn text(args: &[String]) -> Result<(), CliError> {
return Err("text: the document could not be parsed".into());
}
}
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
// permissions password, a document that doesn't allow copying gives no text.
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
// The engine's hint is about editing in the app; on the command line the way in is
// `--password`.
doc.check_copying()
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
}
let pages: Vec<usize> = match selected_page {
Some(page) => vec![page],
None => (0..r.page_count()).collect(),
@@ -588,10 +599,15 @@ fn render(args: &[String]) -> Result<(), CliError> {
// The app renders a display copy that draws comments missing their /AP streams; the CLI
// must agree with it (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
let mut r = PageRenderer::new(
pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")),
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
);
let password = flag(args, "--password");
let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
session.get(id).ok_or("render: the document could not be opened")?.display.clone()
} else {
pdfcraft_engine::display_bytes_for(bytes, password)
};
let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
+136
View File
@@ -0,0 +1,136 @@
//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645).
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn fixture(test: &str) -> PathBuf {
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="page"><occur max="-1"/>
<contentArea x="20pt" y="20pt" w="160pt" h="260pt"/>
<medium short="200pt" long="300pt"/>
</pageArea></pageSet>
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
</subform>
</subform></template></xdp:xdp>"#;
let objects = [
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("form.pdf");
std::fs::write(&path, bytes).unwrap();
path
}
fn success(output: &Output) {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
#[test]
fn info_reports_the_laid_out_pages_and_fields() {
let path = fixture("info");
let original = std::fs::read(&path).unwrap();
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages");
assert_eq!(info["pages"], 2, "top-level page count must agree with the app");
assert_eq!(info["fields"], 1);
assert_eq!(info["pdf_pages"], 1);
assert_eq!(info["acroform_fields"], 0);
assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0]));
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
}
#[test]
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
let path = fixture("text");
let output = Command::new(BIN).arg("text").arg(&path).output().unwrap();
success(&output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}");
let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap();
success(&output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}");
}
#[test]
fn render_matches_the_engine_layout_on_the_second_page() {
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
let path = fixture("render");
let original = std::fs::read(&path).unwrap();
let mut session = pdfcraft_engine::Session::new();
let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap();
let doc = session.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page")));
let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default());
let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(expected.error.is_none(), "{:?}", expected.error);
assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink");
let out = path.with_extension("pam");
let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap();
success(&output);
let actual = std::fs::read(out).unwrap();
let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height);
assert!(actual.starts_with(header.as_bytes()));
assert_eq!(&actual[header.len()..], &expected.rgba[..]);
assert_eq!(std::fs::read(path).unwrap(), original);
}
#[test]
fn a_laid_out_form_keeps_the_original_copy_permissions() {
let path = fixture("permissions");
let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "openme",
owner_password: "boss",
permissions: -17,
encrypt_metadata: true,
seed: [17; 32],
})
.unwrap();
let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
std::fs::write(&path, &original).unwrap();
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying"));
let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap();
success(&output);
assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page"));
let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["pages"], 2);
assert_eq!(info["encrypted"], true);
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original);
}
+39 -3
View File
@@ -565,7 +565,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
// Surface::configure before the app starts. Keep the renderer's other device requirements,
// Surface::configure before the app starts. Keep the renderer's other compatible requirements,
// but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters).
let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
@@ -606,8 +606,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
}
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements.
/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent.
fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits {
required = required.or_worse_values_from(supported);
required.max_texture_dimension_2d = supported.max_texture_dimension_2d;
required
}
@@ -912,16 +913,51 @@ mod tests {
fn surface_limits_preserve_other_requirements_and_never_overrequest() {
for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
for extent in [4096, 8192, 16384] {
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() };
let supported = eframe::wgpu::Limits {
max_texture_dimension_2d: extent,
max_bind_groups: 8,
min_uniform_buffer_offset_alignment: 128,
min_storage_buffer_offset_alignment: 128,
..required.clone()
};
let enabled = super::surface_texture_limits(required.clone(), &supported);
let mut expected = required.clone();
expected.max_texture_dimension_2d = extent;
assert_eq!(enabled, expected);
assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
assert!(enabled.check_limits(&supported));
}
}
}
#[test]
fn surface_limits_fit_v3d_shader_variable_limit() {
// Issue #2383: V3D supports 15 variables, below the default request of 16.
let required = eframe::wgpu::Limits::default();
let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
let enabled = super::surface_texture_limits(required.clone(), &supported);
assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
assert_eq!(enabled, expected);
}
#[test]
fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
let supported = eframe::wgpu::Limits {
max_texture_dimension_2d: 2048,
max_bind_groups: 2,
max_vertex_attributes: 8,
min_uniform_buffer_offset_alignment: 512,
min_storage_buffer_offset_alignment: 512,
..required.clone()
};
let enabled = super::surface_texture_limits(required, &supported);
assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
// Larger minimum alignments are weaker capabilities, unlike maximum limits.
assert_eq!(enabled, supported);
}
#[test]
fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
use eframe::egui_wgpu::winit::surface_fit;
+8 -2
View File
@@ -63,9 +63,15 @@ enum Which {
fn selected(doc: &Document, d: &Dict, which: Which) -> bool {
let subtype = d.name(b"Subtype").unwrap_or_default();
match which {
// Comments are what the comment list calls comments: links, pop-ups and the non-markup
// multimedia and print-production annotations (3D, Screen, Movie, RichMedia,
// PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087).
Which::Comments { comments, fields } => {
let widget = subtype == b"Widget";
!matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments)
if subtype == b"Widget" {
fields
} else {
comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype))
}
}
Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d),
}
+40
View File
@@ -4498,3 +4498,43 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup
/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark,
/// TrapNet, Watermark), which it used to bake into the page and delete with their data.
/// Flattening fields only leaves them alone too; real comments are still flattened.
#[test]
fn flattening_comments_keeps_annotations_that_are_not_comments() {
let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"];
let flattened = ["Text", "Square", "FileAttachment"];
let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect();
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream";
let annots: Vec<String> = all
.iter()
.enumerate()
.map(|(i, s)| {
format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i)
})
.collect();
let refs: Vec<String> = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" "));
let content = stream("", " ");
let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content];
objs.extend(annots.iter().map(String::as_str));
let left = |doc: &Document| -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[0];
let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() };
a.as_array()
.unwrap()
.iter()
.filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned()))
.collect()
};
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len());
assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened");
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget");
let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
}
+30 -6
View File
@@ -3339,6 +3339,35 @@ fn recognize_text_reads_the_euro_sign() {
assert!(text.contains("€250"), "{text}");
}
const SYNTHETIC_SCAN_DPI: f64 = 300.0;
/// A scan of the OCR sentence at the source page's physical size, without a text layer.
fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) {
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let original = &s.get(id).unwrap().info.pages[0];
let size = [original.width, original.height];
// A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap();
// Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge
// the page and change the PDF-point coordinates the placement assertions exercise.
let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap();
(s.open("scan.pdf", None, scan, None).unwrap(), size)
}
#[test]
fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let (id, expected) = synthetic_ocr_scan(&mut s);
let page = &s.get(id).unwrap().info.pages[0];
// Rounding to whole raster pixels may change a page edge by at most one pixel.
let tolerance = 72.0 / SYNTHETIC_SCAN_DPI;
for (actual, expected) in [page.width, page.height].into_iter().zip(expected) {
assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt");
}
assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs");
}
#[test]
fn recognize_text_makes_a_scanned_page_searchable() {
if !ocr::available() {
@@ -3346,12 +3375,7 @@ fn recognize_text_makes_a_scanned_page_searchable() {
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
// A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap();
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, scan, None).unwrap();
let (id, _) = synthetic_ocr_scan(&mut s);
assert_eq!(page_texts(&s, id), [""], "a picture has no text");
let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
+35 -1
View File
@@ -839,6 +839,11 @@ impl Metrics {
fn has_glyph(&self, code: u32) -> bool {
if self.composite {
let cid = self.cid(code);
// CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word
// maps it to a space and gives it a width, and typing with it draws boxes (#3223).
if cid == 0 {
return false;
}
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
@@ -1456,6 +1461,34 @@ mod tests {
assert_eq!(m.encode("Z"), None);
}
/// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a
/// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223).
#[test]
fn composite_fonts_never_encode_with_cid_0() {
let mut doc = Document::new_empty();
let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> <FFFF> endcodespacerange \
1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \
1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end";
let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec())));
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec();
desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)]));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")),
("Encoding", Object::name("Identity-H")),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", Object::Ref(tu)),
],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..]));
assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á");
}
/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is
/// still in the font, but nothing is when the font gives no default width to point at.
#[test]
@@ -1754,7 +1787,8 @@ mod tests {
assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}");
}
let mut reverse: HashMap<char, u32> = HashMap::new();
for (&code, text) in &expected {
// Code 0 is CID 0, `.notdef`, which no character is encoded with.
for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) {
if let Some(c) = only_char(text) {
keep(&mut reverse, c, code);
}
+53 -6
View File
@@ -140,14 +140,16 @@ fn address_char(c: char) -> bool {
fn email(s: &[char], i: usize) -> Option<usize> {
let local = |c: &char| address_char(*c) || "._%+-".contains(*c);
if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
// A match starts at the first character of a run of local characters, which may be
// punctuation ("_admin@host.com"), but the run needs a letter or digit.
if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
return None;
}
let mut j = i;
while j < s.len() && local(&s[j]) {
j += 1;
}
if s.get(j) != Some(&'@') {
if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) {
return None;
}
let d0 = j + 1;
@@ -181,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option<usize> {
if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let (end, n) = digit_run(s, i, &[' ', '-'], 23);
if !(13..=19).contains(&n) || digit(s.get(end)) {
// Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits.
let (end, n) = digit_run(s, i, &[' ', '-'], 29);
// A run longer than that is some other, longer number.
if end == i || run_continues(s, end - 1, 1, &[' ', '-']) {
return None;
}
let digits: Vec<u32> = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect();
luhn(&digits).then_some(end - i)
// The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then
// each shorter run ending at a group boundary, longest first. The dropped tail is at most four
// digits, so a longer number (an IBAN's digits) doesn't match on a prefix.
let mut count = n;
for e in (i + 1..=end).rev() {
if n - count > 4 {
break;
}
if !s.get(e - 1).is_some_and(char::is_ascii_digit) {
continue;
}
let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) };
if boundary && (13..=19).contains(&count) {
let digits: Vec<u32> = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect();
if luhn(&digits) {
return Some(e - i);
}
}
count = count.saturating_sub(1);
}
None
}
fn ssn(s: &[char], i: usize) -> Option<usize> {
@@ -401,6 +424,18 @@ mod tests {
);
}
#[test]
fn emails_with_a_leading_punctuation_character() {
for lead in ["_", "-", ".", "%", "+"] {
let address = format!("{lead}john@example.com");
assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}");
}
// A run of local characters is still matched whole, never from its middle.
assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]);
// Punctuation alone is no local part.
assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::<String>::new());
}
#[test]
fn internationalized_emails() {
assert_eq!(
@@ -421,6 +456,18 @@ mod tests {
);
}
#[test]
fn credit_card_followed_by_another_number() {
for tail in ["12/26", "2026", "123", "12 26"] {
assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}");
}
assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]);
// A run that is not a card at any group boundary still matches nothing.
assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::<String>::new());
// A longer grouped number doesn't match on a card-shaped prefix.
assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::<String>::new());
}
#[test]
fn social_security_numbers() {
assert_eq!(
+22 -1
View File
@@ -1401,6 +1401,7 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) {
///
/// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8):
/// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap.
/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment.
/// Segments on the same baseline that nearly touch are merged (text drawn out of order).
/// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally.
/// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left
@@ -1453,7 +1454,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
let Some(p) = s.first.end.checked_sub(1) else { return false };
let ph = height(p).min(height(i));
let gap = g[0] - glyphs[p].rect[2];
(cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
// A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by
// more than half its own height, but by less than half the height of the full-size glyph beside it.
let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 };
(cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
});
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
if cont
@@ -1636,6 +1640,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
mod tests {
use super::*;
fn em_box(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> TextGlyph {
TextGlyph { text: text.into(), rect: [x0, y0, x1, y1], quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], direction: [1.0, 0.0] }
}
#[test]
fn subscript_stays_in_its_word() {
// "CO2-E": an 8.1 pt "CO" and "-E", with a 4.7 pt "2" drawn 2.7 pt lower (#3174).
let glyphs = vec![
em_box("C", 0.0, 92.5, 5.0, 100.6),
em_box("O", 5.0, 92.5, 11.0, 100.6),
em_box("2", 11.0, 97.9, 13.5, 102.6),
em_box("-", 13.5, 92.5, 16.0, 100.6),
em_box("E", 16.0, 92.5, 21.0, 100.6),
];
assert_eq!(layout(glyphs).plain_text(), "CO2-E");
}
#[test]
fn identity_v_columns_are_searchable_in_reading_order() {
use lopdf::{Document, Object, Stream, dictionary};
+21
View File
@@ -554,6 +554,9 @@ pub struct DocView {
pub pages_panel: Option<Rect>,
/// The Pages-panel gap under the pointer (where dropped files go), when it can take pages.
pub panel_gap: Option<usize>,
/// The current page the Pages panel last followed: it scrolls to the current page's
/// thumbnail only when the page changes.
pub(crate) panel_followed: Option<usize>,
/// How large the page grid draws its pages (1.0 = the usual thumbnails).
grid_zoom: f32,
/// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid
@@ -770,6 +773,7 @@ impl DocView {
grid_gap: None,
pages_panel: None,
panel_gap: None,
panel_followed: None,
grid_zoom: 1.0,
grid_zoom_request: None,
grid_anchor: None,
@@ -1512,6 +1516,13 @@ impl DocView {
}
}
/// A zoom typed as a percentage ("150", "150%", " 62.5 % ") as a zoom factor; `None` unless it is a
/// finite, positive number. `set_zoom` clamps it to the supported range.
pub fn parse_zoom_percent(typed: &str) -> Option<f32> {
let percent = typed.trim().trim_end_matches('%').trim_end().parse::<f32>().ok()?;
(percent.is_finite() && percent > 0.0).then_some(percent / 100.0)
}
/// Standard zoom steps (as in Acrobat's zoom menu).
pub fn zoom_step(&mut self, up: bool) {
const STEPS: [f32; 17] = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0, 16.0, 32.0];
@@ -5152,6 +5163,16 @@ mod tests {
use super::*;
#[test]
fn typed_zoom_percentages_parse() {
for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] {
assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}");
}
for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] {
assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}");
}
}
/// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture
/// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still
/// fills the area's height; neither covers the checkbox corner or the label below.
+30 -4
View File
@@ -694,10 +694,36 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
view.goto = Some((view.current, 0.0));
}
let zoom = format!("{:.0}%", view.zoom * 100.0);
ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| {
ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted));
});
let id = egui::Id::new("zoom-input");
let draft = id.with("draft");
let mut typed = ui.data(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
let edit = egui::TextEdit::singleline(&mut typed)
.id(id)
.desired_width(40.0)
.horizontal_align(Align::Center)
.font(theme::medium(11.0))
.text_color(t.text_muted)
.frame(egui::Frame::NONE)
.margin(vec2(2.0, 2.0));
let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
if r.gained_focus() {
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
}
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(draft));
if r.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Some(zoom) = DocView::parse_zoom_percent(&typed)
{
view.set_zoom(zoom);
}
}
ui.add_space(6.0);
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
view.step_page(true);
+6 -1
View File
@@ -1329,7 +1329,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
ed.max_width = ed.rect.width();
let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01);
ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0);
let font = editor_font(&ed.look, ed.size);
// egui draws the caret at least one row of this font tall. Each row already has its source
// row's height, so the font stays below every row (half the region's smallest size): on a
// 12 pt row under a 24 pt title the caret is as tall as the row, not the title.
let smallest =
ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min));
let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size)));
let text_color = color32(ed.look.color);
let mut done = None;
egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show(
+1
View File
@@ -162,6 +162,7 @@
Next page الصفحة التالية
Previous page الصفحة السابقة
Current page — type a page number or label (such as iv) and press Enter الصفحة الحالية — اكتب رقم صفحة أو تسميتها (مثل iv) ثم اضغط Enter
Zoom — type a percentage and press Enter التكبير — اكتب نسبة مئوية واضغط Enter
Welcome to PdfCraft مرحبًا بك في PdfCraft
An open-source PDF workbench — local, private, and scriptable. منضدة عمل مفتوحة المصدر لملفات PDF — محلية وخاصة وقابلة للبرمجة.
Join the ArtCraft community انضم إلى مجتمع ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1123,6 +1123,7 @@
Next page Следваща страница
Previous page Предишна страница
Current page — type a page number or label (such as iv) and press Enter Текуща страница — въведете номер или етикет на страница (например iv) и натиснете Enter
Zoom — type a percentage and press Enter Мащаб — въведете процент и натиснете Enter
Open a PDF ({key}) Отвори PDF файл ({key})
Dark gray theme Тъмносива тема
Light theme Светла тема
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -279,6 +279,7 @@ key Space Mezerník
Next page Další stránka
Previous page Předchozí stránka
Current page — type a page number or label (such as iv) and press Enter Aktuální stránka — zadejte číslo nebo označení stránky (třeba iv) a stiskněte Enter
Zoom — type a percentage and press Enter Lupa — zadejte procenta a stiskněte Enter
Welcome to PdfCraft Vítá vás PdfCraft
An open-source PDF workbench — local, private, and scriptable. Open-source dílna pro PDF — lokální, soukromá a skriptovatelná.
Join the ArtCraft community Připojte se ke komunitě ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -135,6 +135,7 @@
Next page Nächste Seite
Previous page Vorherige Seite
Current page — type a page number or label (such as iv) and press Enter Aktuelle Seite – gib eine Seitenzahl oder -beschriftung ein (z. B. iv) und drücke die Eingabetaste
Zoom — type a percentage and press Enter Zoom – gib einen Prozentwert ein und drücke die Eingabetaste
Welcome to PdfCraft Willkommen bei PdfCraft
An open-source PDF workbench — local, private, and scriptable. Eine Open-Source-PDF-Werkbank – lokal, privat und skriptfähig.
Join the ArtCraft community Tritt der ArtCraft-Community bei
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -282,6 +282,7 @@
Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση
Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα.
Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter
Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter
Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Τα αρχεία που ανοίγετε στο PdfCraft εμφανίζονται εδώ. Σύρετε ένα PDF οπουδήποτε για να το ανοίξετε.
Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Τα σενάρια εκτελούνται σε απομονωμένο περιβάλλον χωρίς πρόσβαση σε αρχεία ή δίκτυο. Με απενεργοποιημένη τη JavaScript, οι τυπικές λειτουργίες μορφοποίησης, επικύρωσης και υπολογισμού του Acrobat εξακολουθούν να λειτουργούν.
Move up Μετακίνηση πάνω
1 # Greek (el) interface catalog. Columns: context <TAB> English source <TAB> Greek translation.
282
283
284
285
286
287
288
+1
View File
@@ -158,6 +158,7 @@
Next page Página siguiente
Previous page Página anterior
Current page — type a page number or label (such as iv) and press Enter Página actual: escribe un número o una etiqueta de página (por ejemplo, iv) y pulsa Intro
Zoom — type a percentage and press Enter Zoom: escribe un porcentaje y pulsa Intro
Welcome to PdfCraft Te damos la bienvenida a PdfCraft
An open-source PDF workbench — local, private, and scriptable. Un banco de trabajo PDF de código abierto: local, privado y automatizable.
Join the ArtCraft community Únete a la comunidad de ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -135,6 +135,7 @@
Next page Page suivante
Previous page Page précédente
Current page — type a page number or label (such as iv) and press Enter Page actuelle — saisissez un numéro ou une étiquette de page (comme iv) et appuyez sur Entrée
Zoom — type a percentage and press Enter Zoom — saisissez un pourcentage et appuyez sur Entrée
Welcome to PdfCraft Bienvenue dans PdfCraft
An open-source PDF workbench — local, private, and scriptable. Un atelier PDF open source — local, privé et automatisable.
Join the ArtCraft community Rejoindre la communauté ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -133,6 +133,7 @@
Next page Következő oldal
Previous page Előző oldal
Current page — type a page number or label (such as iv) and press Enter Aktuális oldal — írj be egy oldalszámot vagy címkét (például iv), majd nyomd meg az Entert
Zoom — type a percentage and press Enter Nagyítás – írjon be egy százalékértéket, és nyomja meg az Entert
Welcome to PdfCraft Üdvözöljük a PdfCraftban
An open-source PDF workbench — local, private, and scriptable. Nyílt forráskódú PDF-munkapad — helyi, privát és parancsfájlokkal vezérelhető.
Join the ArtCraft community Csatlakozz az ArtCraft közösséghez
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -159,6 +159,7 @@
Next page Pagina successiva
Previous page Pagina precedente
Current page — type a page number or label (such as iv) and press Enter Pagina corrente: digita un numero o un'etichetta di pagina (ad esempio iv) e premi Invio
Zoom — type a percentage and press Enter Zoom: digita una percentuale e premi Invio
Welcome to PdfCraft Benvenuto in PdfCraft
An open-source PDF workbench — local, private, and scriptable. Un banco di lavoro PDF open source: locale, riservato e programmabile.
Join the ArtCraft community Unisciti alla community di ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -162,6 +162,7 @@
Next page 次のページ
Previous page 前のページ
Current page — type a page number or label (such as iv) and press Enter 現在のページ:ページ番号またはラベル(例:iv)を入力して Enter
Zoom — type a percentage and press Enter ズーム — パーセンテージを入力して Enter キーを押します
Welcome to PdfCraft PdfCraft へようこそ
An open-source PDF workbench — local, private, and scriptable. ローカルで処理し、プライバシーを守る、スクリプト対応のオープンソース PDF ツール。
Join the ArtCraft community ArtCraft コミュニティに参加
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -123,6 +123,7 @@
Next page Келесі бет
Previous page Алдыңғы бет
Current page — type a page number or label (such as iv) and press Enter Ағымдағы бет — бет нөмірін немесе белгісін (мысалы, iv) теріңіз де, Enter пернесін басыңыз
Zoom — type a percentage and press Enter Масштаб — пайызды енгізіп, Enter пернесін басыңыз
Welcome to PdfCraft PdfCraft-ге қош келдіңіз
An open-source PDF workbench — local, private, and scriptable. Ашық бастапқы кодты PDF жұмыс орны — жергілікті, жеке әрі скрипттерді қолдайтын.
Join the ArtCraft community ArtCraft қауымдастығына қосылыңыз
1 Menu Мәзір
123 Next page Келесі бет
124 Previous page Алдыңғы бет
125 Current page — type a page number or label (such as iv) and press Enter Ағымдағы бет — бет нөмірін немесе белгісін (мысалы, iv) теріңіз де, Enter пернесін басыңыз
126 Zoom — type a percentage and press Enter Масштаб — пайызды енгізіп, Enter пернесін басыңыз
127 Welcome to PdfCraft PdfCraft-ге қош келдіңіз
128 An open-source PDF workbench — local, private, and scriptable. Ашық бастапқы кодты PDF жұмыс орны — жергілікті, жеке әрі скрипттерді қолдайтын.
129 Join the ArtCraft community ArtCraft қауымдастығына қосылыңыз
+1
View File
@@ -431,6 +431,7 @@
Current page Dabartinis puslapis
Current page ({n}) Dabartinis puslapis ({n})
Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“
Zoom — type a percentage and press Enter Mastelis – įveskite procentus ir paspauskite Enter
Custom Pasirinktinis
Custom scale: Pasirinktinis mastelis:
Custom text: Pasirinktinis tekstas:
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -158,6 +158,7 @@
Next page Następna strona
Previous page Poprzednia strona
Current page — type a page number or label (such as iv) and press Enter Bieżąca strona — wpisz numer lub etykietę strony (np. iv) i naciśnij Enter
Zoom — type a percentage and press Enter Powiększenie — wpisz wartość procentową i naciśnij Enter
Welcome to PdfCraft Witamy w PdfCraft
An open-source PDF workbench — local, private, and scriptable. Otwarty zestaw narzędzi do PDF — lokalny, prywatny i obsługujący skrypty.
Join the ArtCraft community Dołącz do społeczności ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1067,6 +1067,7 @@
Next page Próxima página
Previous page Página anterior
Current page — type a page number or label (such as iv) and press Enter Página atual — digite o número da página ou rótulo (como iv) e pressione Enter
Zoom — type a percentage and press Enter Zoom — digite uma porcentagem e pressione Enter
Open a PDF ({key}) Abrir um PDF ({key})
Dark gray theme Tema cinza escuro
Light theme Tema claro
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -155,6 +155,7 @@
Next page Следующая страница
Previous page Предыдущая страница
Current page — type a page number or label (such as iv) and press Enter Текущая страница — введите номер или метку страницы (например, iv) и нажмите Enter
Zoom — type a percentage and press Enter Масштаб — введите процент и нажмите Enter
Welcome to PdfCraft Добро пожаловать в PdfCraft
An open-source PDF workbench — local, private, and scriptable. Открытый PDF-редактор — локальный, приватный и программируемый.
Join the ArtCraft community Присоединяйтесь к сообществу ArtCraft
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -519,6 +519,7 @@
Current page Trenutna stran
Current page ({n}) Trenutna stran ({n})
Current page — type a page number or label (such as iv) and press Enter Trenutna stran — vnesite številko ali oznako strani (npr. iv) in pritisnite Enter
Zoom — type a percentage and press Enter Povečava – vnesite odstotek in pritisnite Enter
Custom Po meri
Custom scale: Merilo po meri:
Custom stamps arrive on the web with file pickers for images Lastni žigi bodo v spletni različici na voljo z izbirniki slikovnih datotek
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -139,6 +139,7 @@ comment menu Edit సవరించు
Next page తదుపరి పేజీ
Previous page మునుపటి పేజీ
Current page — type a page number or label (such as iv) and press Enter ప్రస్తుత పేజీ — పేజీ సంఖ్యను లేదా లేబుల్‌ను (iv వంటిది) టైప్ చేసి Enter నొక్కండి
Zoom — type a percentage and press Enter జూమ్ — శాతాన్ని టైప్ చేసి Enter నొక్కండి
Welcome to PdfCraft PdfCraftకు స్వాగతం
An open-source PDF workbench — local, private, and scriptable. ఓపెన్-సోర్స్ PDF వర్క్‌బెంచ్ — మీ కంప్యూటర్‌లోనే నడుస్తుంది, గోప్యంగా ఉంటుంది, స్క్రిప్ట్‌లతో ఆటోమేట్ చేయవచ్చు.
Join the ArtCraft community ArtCraft కమ్యూనిటీలో చేరండి
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -120,6 +120,7 @@
Next page Sonraki sayfa
Previous page Önceki sayfa
Current page — type a page number or label (such as iv) and press Enter Geçerli sayfa — bir sayfa numarası veya etiketi (örneğin iv) yazıp Enter'a basın
Zoom — type a percentage and press Enter Yakınlaştırma — bir yüzde yazıp Enter tuşuna basın
Welcome to PdfCraft PdfCraft'a Hoş Geldiniz
An open-source PDF workbench — local, private, and scriptable. Açık kaynaklı bir PDF çalışma alanı — yerel, gizli ve komut dosyalarıyla yönetilebilir.
Join the ArtCraft community ArtCraft topluluğuna katılın
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
120
121
122
123
124
125
126
+1
View File
@@ -160,6 +160,7 @@
Next page Наступна сторінка
Previous page Попередня сторінка
Current page — type a page number or label (such as iv) and press Enter Поточна сторінка — введіть номер або позначку сторінки (наприклад iv) і натисніть Enter
Zoom — type a percentage and press Enter Масштаб — введіть відсоток і натисніть Enter
Welcome to PdfCraft Вітаємо в PdfCraft
An open-source PDF workbench — local, private, and scriptable. Відкритий інструментарій для PDF — локальний, приватний і з підтримкою сценаріїв.
Join the ArtCraft community Приєднатися до спільноти ArtCraft
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -15,6 +15,7 @@
Zoom Thu phóng
Actual size Kích thước thực
Zoom to page level Thu phóng vừa trang
Zoom — type a percentage and press Enter Thu phóng — nhập tỷ lệ phần trăm rồi nhấn Enter
Fit to width Vừa chiều rộng
Display theme Chủ đề hiển thị
Side panels Bảng bên
1 # Vietnamese (vi) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
15
16
17
18
19
20
21
+1
View File
@@ -1130,6 +1130,7 @@
Next page 下一页
Previous page 上一页
Current page — type a page number or label (such as iv) and press Enter 当前页 — 输入页码或标签(如 iv)后按 Enter
Zoom — type a percentage and press Enter 缩放 — 输入百分比并按 Enter
Open a PDF ({key}) 打开 PDF ({key})
Dark gray theme 深灰色主题
Light theme 浅色主题
Can't render this file because it contains an unexpected character in line 9 and column 16.
+19 -18
View File
@@ -109,7 +109,7 @@
Sanitize 清理
Mark all 全部標記
Save changes to “{name}” before closing? 在關閉前儲存對「{name}」的更改?
Your changes will be lost if you don't save them. 如果不儲存,更改將會丟失。
Your changes will be lost if you don't save them. 如果不儲存,更改將會遺失。
Password required 需要密碼
“{name}” is protected. Enter a password to open it. 「{name}」受保護。請輸入密碼以開啟。
Password 密碼
@@ -190,7 +190,7 @@
Discard User Data 捨棄使用者資料
Clean Up 清理
Color Images 彩色圖片
Grayscale Images 灰度圖片
Grayscale Images 灰階圖片
Bicubic downsampling to 雙三次降取樣至
for images above (高於此解析度的圖片)
Compression 壓縮
@@ -303,8 +303,8 @@
Negative number style: 負數樣式:
Format: 格式:
Custom: 自訂:
Zip Code 郵政編碼
Zip Code + 4 郵政編碼 + 4
Zip Code 郵遞區號
Zip Code + 4 郵遞區號 + 4
Phone Number 電話號碼
Social Security Number 社會安全號碼
Arbitrary Mask 任意掩碼
@@ -503,7 +503,7 @@
Continue 繼續
Configure a Digital ID for Signing 設定簽署用的數位 ID
Use a Digital ID from a file 使用檔案中的數位 ID
Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自簽名,儲存到受密碼保護的檔案)
Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自我簽署,儲存到受密碼保護的檔案)
Name 名稱
Organizational Unit 組織單位
Organization Name 組織名稱
@@ -511,7 +511,7 @@
Country/Region 國家/地區
Key Algorithm 金鑰演算法
Confirm Password 確認密碼
Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自簽名 ID 通常不受他人信任。
Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自我簽署 ID 通常不受他人信任。
File (.p12, .pfx) 檔案 (.p12, .pfx)
Digital ID 數位 ID
Browse… 瀏覽…
@@ -566,7 +566,7 @@
Public key 公鑰
Key usage 金鑰用途
Not present 不存在
Self-signed 自簽名
Self-signed 自我簽署
Edit Action 編輯操作
New Action 新建操作
Action name: 操作名稱:
@@ -772,9 +772,9 @@
Social Security Numbers 社會安全號碼
Dates 日期
Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。
No matches. 無匹配。
No matches. 無相符。
Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此處未顯示部分書籤:面板列出的書籤數量和巢狀深度有限。
{n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。
{n} match(es) marked for redaction. 已標記 {n} 個相符以待遮蔽內容。
Multiple words or phrases 多個單字或片語
One word or phrase per line 每行一個單字或片語
Import list… 匯入清單…
@@ -815,7 +815,7 @@
{name} (default) {name}(預設)
Copies: 份數:
Collate 逐份列印
Print in grayscale 灰度列印
Print in grayscale 灰階列印
Two-sided: 雙面:
Off 關
Flip on long edge 沿長邊翻轉
@@ -1074,6 +1074,7 @@
Next page 下一頁
Previous page 上一頁
Current page — type a page number or label (such as iv) and press Enter 目前頁面:輸入頁碼或頁面標籤(例如 iv),再按 Enter
Zoom — type a percentage and press Enter 縮放 — 輸入百分比並按 Enter
Open a PDF ({key}) 開啟 PDF({key})
Dark gray theme 深灰色主題
Light theme 淺色主題
@@ -1174,7 +1175,7 @@
Add a comment 新增註解
No comments yet. 尚無註解。
Pick a tool in the quick bar, or type above. 在快捷欄中選擇工具,或在上方輸入。
No comments match. 沒有匹配的註解。
No comments match. 沒有相符的註解。
Unknown author 未知作者
No colour 無顏色
Remove checkmark 刪除勾選標記
@@ -1231,13 +1232,13 @@
Open in a new tab 在新索引標籤中開啟
This document has no pages. 本文件沒有頁面。
Find text 尋找文字
No matches 無匹配
No matches 無相符
Searching… {s}/{p} 正在搜尋… {s}/{p}
{c} of {n}{more} {c} / {n}{more}
Previous ({key}) 上一個 ({key})
Next ({key}) 下一個 ({key})
Find options 尋找選項
Whole words only 僅全字匹配
Whole words only 僅全字相符
Case-sensitive 區分大小寫
Close (Esc) 關閉 (Esc)
Align 對齊
@@ -1302,7 +1303,7 @@
· password-protected · 受密碼保護
Recover 恢復
Discard 放棄
Next / previous match 下一個/上一個符合項目
Next / previous match 下一個/上一個相符項目
Copy selected text 複製所選文字
Select a word 選取一個詞
Clear selection / close find 清除選取/關閉尋找
@@ -1618,7 +1619,7 @@
the name is not a plain file name 名稱不是單純的檔案名稱
Wrote 1 file to {dir} 已將 1 個檔案寫入{dir}
Wrote {n} files to {dir} 已將{n}個檔案寫入{dir}
No pages match those choices 所選條件沒有匹配的頁面
No pages match those choices 所選條件沒有相符的頁面
Couldn't split: {e} 無法拆分:{e}
Couldn't summarize comments: {e} 無法彙總註解:{e}
Couldn't open the result: {e} 無法開啟結果:{e}
@@ -1693,8 +1694,8 @@
What word or phrase would you like to search for? 您想搜尋哪個詞或短語?
Searching… {s} of {p} pages 正在搜尋… {s}/{p} 頁
(searching… {s} of {p} pages) (正在搜尋… {s}/{p} 頁)
1 instance 1 個匹配
{n} instances {n} 個匹配
1 instance 1 個相符
{n} instances {n} 個相符
Go to this result 轉到此結果
Highlight 突出顯示
Draw 繪製
@@ -1743,7 +1744,7 @@
Tool 工具
Command 指令
Search tools and commands… 搜尋工具與指令…
No matching tools 找不到符合的工具
No matching tools 找不到相符的工具
All tools 所有工具
Read 閱讀
Convert 轉換
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1 -1
View File
@@ -2565,7 +2565,7 @@ impl PdfCraftApp {
self.palette_query = value.to_string();
}
("page", Some(v)) => v.go_to_page(value.parse::<usize>().map_err(|e| e.to_string())?.saturating_sub(1)),
("zoom", Some(v)) => v.set_zoom(value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())? / 100.0),
("zoom", Some(v)) => v.set_zoom(DocView::parse_zoom_percent(value).ok_or("zoom must be a percentage above 0")?),
("layout", Some(v)) => {
let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?;
v.set_display(layout, continuous);
+15 -1
View File
@@ -1064,6 +1064,11 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
{
delete_pages(view, info.pages.len());
}
// A new current page scrolls its thumbnail into view, as little as it takes (Acrobat), but
// not while thumbnails are dragged: then the change counts as seen. A wheel turned or a
// scroll bar dragged by the user already wins over a scroll to a place in egui.
let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none();
view.panel_followed = Some(view.current);
let w = (ui.available_width() - 40.0).min(150.0);
let count = info.pages.len();
let mut rows: Vec<Rect> = Vec::with_capacity(info.pages.len());
@@ -1108,7 +1113,14 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.border }),
egui::StrokeKind::Outside,
);
ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted }));
let label = ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked {
t.accent_text
} else {
t.text_muted
}));
if follow && selected {
ui.scroll_to_rect(rect.union(label.rect), None);
}
let resp = theme::hand(resp);
if resp.clicked() {
let m = ui.input(|i| i.modifiers);
@@ -1116,6 +1128,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
view.click_page(i, m, true);
} else {
view.clear_page_selection(Some(i));
// A page clicked here is in view already: the panel stays put.
view.panel_followed = Some(i);
*nav = Some(Nav::Page(i));
}
}
+52
View File
@@ -3832,6 +3832,58 @@ fn one_editor_contains_mixed_styles_and_visual_rows() {
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3);
}
#[test]
fn the_caret_on_a_small_row_is_as_tall_as_that_row() {
// It was one row of the region's first (18 pt) size tall, reaching into the row below.
let bytes = source_font_fixture_with_text(
"BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \
BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \
BT /F2 10 Tf 20 195 Td (Last 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.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 k = page.width() / 300.0;
let screen = |x: f32, y: f32| egui::pos2(page.left() + x * k, page.top() + (300.0 - y) * k);
let click = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| {
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(4);
};
click(&mut h, screen(30.0, 245.0));
assert!(h.state().views[0].line_editor.is_some(), "the region opens");
let row = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text == "Last row").unwrap().rect;
click(&mut h, screen(40.0, ((row[1] + row[3]) / 2.0) as f32));
let row_height = (row[3] - row[1]) as f32 * k;
let (left, right) = (screen(row[0] as f32, 0.0).x - 2.0, screen(row[2] as f32, 0.0).x + 2.0);
let middle = screen(0.0, ((row[1] + row[3]) / 2.0) as f32).y;
// The caret: a vertical segment within the row's span, through its middle.
let caret = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::LineSegment { points: [a, b], .. }
if (a.x - b.x).abs() < 0.01 && (left..right).contains(&a.x) && a.y.min(b.y) < middle && a.y.max(b.y) > middle =>
{
Some((b.y - a.y).abs())
}
_ => None,
})
.expect("a caret on the last row");
assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row");
}
/// Opt-in private-document regression. This never captures images or saves the source file.
/// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored.
#[test]
+29
View File
@@ -617,6 +617,35 @@ fn default_zoom_is_used_for_new_documents() {
assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5));
}
#[test]
fn the_rail_zoom_field_sets_a_typed_zoom() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::canvas::Fit;
let mut h = harness(&[]);
let type_zoom = |h: &mut Harness<'static, PdfCraftApp>, typed: &str, key: Key| {
h.get_by_role_and_label(Role::TextInput, "Zoom").click();
h.run_steps(2);
h.get_by_role_and_label(Role::TextInput, "Zoom").type_text(typed);
h.run_steps(1);
h.key_press(key);
h.run_steps(3);
};
type_zoom(&mut h, "150", Key::Enter);
assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.5));
type_zoom(&mut h, "62.5 %", Key::Enter);
assert_eq!(h.state().views[0].zoom, 0.625);
for (typed, key) in [("abc", Key::Enter), ("0", Key::Enter), ("300", Key::Escape)] {
type_zoom(&mut h, typed, key);
assert_eq!(h.state().views[0].zoom, 0.625, "{typed}");
}
type_zoom(&mut h, "9000", Key::Enter);
assert_eq!(h.state().views[0].zoom, 64.0);
h.state_mut().views[0].set_zoom(2.0);
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Zoom").value().as_deref(), Some("200%"));
}
#[test]
fn preferences_set_the_default_zoom() {
use egui_kittest::kittest::Queryable;
+64
View File
@@ -1304,6 +1304,70 @@ fn pages_panel_thumbnails_have_a_context_menu() {
assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2]));
}
/// Where the Pages panel draws page `n`'s thumbnail.
fn thumbnail_rect(h: &Harness<'static, PdfCraftApp>, n: usize) -> egui::Rect {
h.get_all_by_label(&format!("Page {n}")).find(|t| t.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect()
}
/// Whether `r` lies wholly in the part of the Pages panel in view.
fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool {
h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r)
}
/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages
/// panel to show it.
#[test]
fn going_to_a_far_page_scrolls_its_thumbnail_into_view() {
let mut h = pages_panel_harness(&[(200, 300); 30]);
assert!(!in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 starts out of sight");
h.state_mut().views[0].go_to_page(24);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 24);
assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25's thumbnail is shown");
}
/// #2781: a page whose thumbnail is already in view leaves the panel where it is, rather than
/// centring the thumbnail on every page change.
#[test]
fn going_to_a_page_in_view_leaves_the_pages_panel_still() {
// Landscape pages, so several thumbnails share the panel.
let mut h = pages_panel_harness(&[(300, 200); 30]);
h.state_mut().views[0].go_to_page(14);
h.run_steps(4);
let clip = h.state().views[0].pages_panel.expect("the Pages panel is shown");
// The topmost thumbnail wholly in view, other than the current page's.
let (n, before) =
(1..=30).map(|n| (n, thumbnail_rect(&h, n))).find(|(n, r)| *n != 15 && in_panel_view(&h, *r)).expect("another thumbnail in view");
assert!(
(before.center().y - clip.center().y).abs() > 50.0,
"page {n} at {before:?} is away from the middle of {clip:?}, where centring would move it"
);
h.state_mut().views[0].go_to_page(n - 1);
h.run_steps(4);
assert_eq!(h.state().views[0].current, n - 1);
assert_eq!(thumbnail_rect(&h, n), before, "page {n}'s thumbnail stayed put");
}
/// #2781: while the user drags thumbnails, a page change does not pull the panel away from
/// where they are being dropped.
#[test]
fn a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel() {
let mut h = pages_panel_harness(&[(200, 300); 30]);
let first = thumbnail_rect(&h, 1);
let at = first.center();
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(1);
h.hover_at(at + egui::vec2(0.0, 30.0));
h.run_steps(2);
assert_eq!(h.state().views[0].panel_drag, Some(vec![0]), "page 1 is being dragged");
h.state_mut().views[0].go_to_page(24);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 24);
assert_eq!(thumbnail_rect(&h, 1), first, "the panel stays put under the drag");
}
/// #444, #186: a mouse-wheel notch scrolls as far as the scroll speed preference says;
/// trackpad (point) scrolling keeps its own speed. The choice is saved.
#[test]
+7 -2
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2572: repeated XFA print buttons dispatch to their shared click script; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
>
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -136,10 +138,10 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| 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 for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons (#2572). Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| 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 for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons (#2572). Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). 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. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| 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 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. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. 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 |
@@ -184,8 +186,11 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
## Revision history
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
| 2026-10-15 | minor | M12/#2572: repeated page-master buttons use the clicked field's SOM for XFA script dispatch; synthetic engine/tool regressions, including save/reopen. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
+1 -1
View File
@@ -201,7 +201,7 @@ full parity in that area.
| C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) |
| D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) |
| E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) |
| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity |
| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity |
| G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view |
| H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version |
| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners |
+9 -9
View File
@@ -950,8 +950,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"]
notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display"
evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/view.rs::the_rail_zoom_field_sets_a_typed_zoom"]
notes = "Typed into the right rail's zoom field (150, 150% or 62.5 %, Enter applies, Escape discards, clamped to 8–6400%) or set through the control channel's zoom option; no preset dropdown in the field yet. PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display"
[[feature]]
id = "view.zoom-actual-size"
@@ -1262,8 +1262,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"]
notes = ""
evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"]
notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
[[feature]]
id = "view.thumbnail-context-ops"
@@ -1866,7 +1866,7 @@ milestone = "M7"
status = "partial"
commands = ["edit.edit_text"]
tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md"
[[feature]]
@@ -4208,8 +4208,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.flatten"]
tools = ["doc_flatten"]
evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"]
notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted"
evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"]
notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)"
[[feature]]
id = "comment.migrate"
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
evidence = ["crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
notes = "Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
evidence = ["crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
notes = "Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"