From 727555ca293fec129e93f9a8cb4077ab206d972d Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 13:37:03 -0700 Subject: [PATCH 001/207] fix(xtask): let a standalone crate dev-depend on test support The standalone rule ran before the testkit exemption and returned early, so `cos`, `geom`, `filters`, `crypt` and `arlington` could not use `pdfcraft-testkit` or `pdfcraft-oracle` even as a dev-dependency. Both crates were already registered in the layering table as `Class::Testkit`, so the first real use of either from a standalone crate would have been rejected. A dev-dependency is not part of the graph a standalone crate publishes, which is what the rule protects; a normal dependency on test support, and any dependency on a product crate, are still rejected. The existing test covered neither direction of the standalone case. Co-Authored-By: Claude Opus 5 --- xtask/src/layers.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 1bda1dc4d7..a0907cb1a6 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -198,11 +198,17 @@ pub fn check(crates: &[Crate]) -> Vec { continue; } if d.workspace { - if matches!(class, Class::Standalone(_)) && !standalone_allowed(&c.name, &d.name) { + let dep_class = classify(&d.name); + // Test support is a dev-dependency only (`Class::Testkit`) and so never reaches + // the product graph a standalone crate publishes. Checking it against the + // standalone allow-list would stop `cos` using the shared oracles in its own + // tests, which is the one thing those crates exist for. + let testkit_dev = dep_class == Some(Class::Testkit) && d.kind == DepKind::Dev; + if matches!(class, Class::Standalone(_)) && !testkit_dev && !standalone_allowed(&c.name, &d.name) { out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); continue; } - match classify(&d.name) { + match dep_class { None => {} Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => { out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() }); @@ -322,6 +328,17 @@ mod tests { assert!(check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Dev, true)])]).is_empty()); let v = check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); + + // A standalone crate may use test support in its own tests: a dev-dependency is not part + // of the graph the standalone rule protects. `cos` is where the qpdf oracle is needed. + assert!(check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Dev, true)])]).is_empty()); + assert!(check(&[c("pdfcraft-geom", &[("pdfcraft-testkit", Dev, true)])]).is_empty()); + // ...but only as a dev-dependency, and only for test support. Product crates are still + // off-limits to a standalone crate however they are depended on. + let v = check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Normal, true)])]); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}"); + let v = check(&[c("pdfcraft-cos", &[("pdfcraft-model", Dev, true)])]); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}"); } #[test] From 0754740fd9c9c2215e388338edf4c4a44981b846 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 13:37:14 -0700 Subject: [PATCH 002/207] feat(oracle): make a missing external oracle loud instead of a silent pass Every test that wanted an external oracle probed for it by hand and chose for itself what to do when it was missing. The common choice -- print a line and return -- reports as a pass, so a suite with no oracles installed looked exactly like one in which every oracle agreed. That was not hypothetical. No CI runner installed qpdf, so `qpdf_agrees` had never executed on a runner, while README.md said saved output was "independently checked with qpdf". For R2-R4 that test is the only check of owner-password authentication (Algorithm 7) that does not go through our own code. `pdfcraft-oracle` holds the probes and one rule: a missing oracle is a declared skip or a failure, never a silent pass. `PDFCRAFT_ORACLES_REQUIRED` picks which, on the same convention as `CRAFT_FONTS_REQUIRED`. CI installs qpdf and sets it, so a skip notice there is now a red build. The crate has no panicking path of its own; `or_skip!` expands its assert into the caller, which is test code. Verified by running the qpdf tests against qpdf 12.4.2 for the first time -- they pass, so the README claim is now both enforced and true. Both gate directions were watched: absent and unrequired skips with a notice, absent and required fails with the install command in the message. Only qpdf is installed in CI, because it is the only tool the test job uses. The Poppler pixel comparison in crates/organize stays ignored until a change turns it on and verifies it there. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 13 +- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 5 +- crates/cos/Cargo.toml | 1 + crates/cos/tests/encryption.rs | 15 +- crates/cos/tests/writer.rs | 24 +--- crates/oracle/Cargo.toml | 11 ++ crates/oracle/README.md | 70 ++++++++++ crates/oracle/src/lib.rs | 246 +++++++++++++++++++++++++++++++++ crates/organize/Cargo.toml | 1 + crates/organize/src/tests.rs | 5 +- parity/acrobat-features.toml | 2 +- xtask/Cargo.toml | 1 + xtask/src/gates.rs | 9 +- 15 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 crates/oracle/Cargo.toml create mode 100644 crates/oracle/README.md create mode 100644 crates/oracle/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8249dcf0c7..5ac7bdeaf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,13 +48,22 @@ jobs: echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV" echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV" - - name: Install Linux system dependencies (egui/winit/rfd) + - name: Install Linux system dependencies (egui/winit/rfd) and the oracles if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + libgl1-mesa-dev libgtk-3-dev \ + qpdf + + # The independent oracles (crates/oracle) are only worth having when they run. With this + # set, a missing one fails the job instead of printing a skip notice that reads as a pass. + # Only the tools the job actually uses are installed above; a test that needs another one + # (Poppler, for the pixel comparison in crates/organize) stays `#[ignore]`d until then. + - name: Require the oracles (Linux) + if: runner.os == 'Linux' + run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: diff --git a/Cargo.lock b/Cargo.lock index f2acd78dcd..2984a688f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3983,6 +3983,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", + "pdfcraft-oracle", "proptest", "serde_json", "thiserror 2.0.21", @@ -4160,6 +4161,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-oracle" +version = "0.8.0" + [[package]] name = "pdfcraft-organize" version = "0.8.0" @@ -4167,6 +4172,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-oracle", "thiserror 2.0.21", ] @@ -7946,6 +7952,7 @@ version = "0.1.0" dependencies = [ "anyhow", "lopdf", + "pdfcraft-oracle", "png 0.18.1", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 87422986d2..6940b9acbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" } pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } pdfcraft-js = { path = "crates/js", version = "0.8.0" } pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } diff --git a/README.md b/README.md index b20179fff4..86f7d7643a 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Open **Organize pages** to see every page at once: **Saves you can trust:** - *Incremental:* the original bytes stay byte-for-byte intact. - *Atomic:* the file is written to a temporary copy, then swapped in. -- *Verified:* independently checked with qpdf. +- *Verified:* independently checked with qpdf, on every CI run. When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. @@ -365,7 +365,8 @@ On top of those, two corpus sweeps run over real-world files: - **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes. - **Open, edit and save round trips:** 958 succeed. -The output is verified with independent tools: hayro, qpdf and poppler. +The output is verified against independent implementations: hayro and qpdf on every CI run, and +poppler over the corpus on demand (`cargo xtask text-oracle`). PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md). diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index f02f63c527..1be1561e63 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -14,6 +14,7 @@ pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-oracle = { workspace = true } serde_json = { workspace = true } proptest = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index 6868a6dc21..cf519df67d 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -202,24 +202,23 @@ fn unencrypted_metadata_is_left_readable() { let _ = ObjRef::new(1, 0); } -/// Second independent oracle when qpdf is installed: it must accept both passwords, identify -/// the owner password, and find no structural errors. +/// The second independent oracle: qpdf must accept both passwords, identify the owner password, +/// and find no structural errors. For R2-R4 this is the *only* check of owner-password +/// authentication (Algorithm 7) that does not go through our own code, so it is the one place a +/// silent skip would cost the most -- see `pdfcraft-oracle`, which makes absence loud. #[test] fn qpdf_agrees() { - if std::process::Command::new("qpdf").arg("--version").output().is_err() { - eprintln!("qpdf not installed; skipping"); - return; - } + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { - let out = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); + let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}"); - let check = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap(); assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout)); } } diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index b7514cc053..87462f4678 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -159,18 +159,14 @@ fn classic_table() -> Vec { bytes } -fn qpdf_check(bytes: &[u8]) -> Option { - let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else { - return None; - }; - assert!(version.status.success()); +fn qpdf_check(qpdf: &pdfcraft_oracle::Oracle, bytes: &[u8]) -> std::process::Output { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id())); std::fs::write(&path, bytes).unwrap(); - let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg("--check").arg(&path).output().unwrap(); let _ = std::fs::remove_file(&path); - Some(check) + check } fn page(doc: &Document) -> Dict { @@ -181,10 +177,8 @@ fn page(doc: &Document) -> Dict { } fn assert_qpdf_clean(bytes: &[u8]) { - let Some(check) = qpdf_check(bytes) else { - eprintln!("qpdf not installed: skipped"); - return; - }; + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); + let check = qpdf_check(&qpdf, bytes); let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr)); assert!(check.status.success() && !report.contains("WARNING"), "{report}"); } @@ -247,16 +241,12 @@ fn incremental_save_keeps_a_trailer_only_edit() { #[test] fn qpdf_accepts_object_stream_output() { - let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else { - eprintln!("qpdf not installed: skipped"); - return; - }; - assert!(out.status.success()); + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); let doc = Document::open(Arc::new(fixture())).unwrap(); let packed = write_full(&doc, &SaveOptions::default()).unwrap(); let path = std::env::temp_dir().join(format!("pdfcraft-objstm-{}.pdf", std::process::id())); std::fs::write(&path, &packed).unwrap(); - let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg("--check").arg(&path).output().unwrap(); let _ = std::fs::remove_file(&path); let report = String::from_utf8_lossy(&check.stdout); assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}"); diff --git a/crates/oracle/Cargo.toml b/crates/oracle/Cargo.toml new file mode 100644 index 0000000000..45b27f5d9c --- /dev/null +++ b/crates/oracle/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pdfcraft-oracle" +description = "External oracle processes PdfCraft checks its own output against, and the rule that a missing one is loud" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/oracle/README.md b/crates/oracle/README.md new file mode 100644 index 0000000000..d02f5b5f9f --- /dev/null +++ b/crates/oracle/README.md @@ -0,0 +1,70 @@ +# pdfcraft-oracle + +The external programs PdfCraft checks its own output against, and one rule: **a missing oracle is +either a declared skip or a failure, never a silent pass.** + +- **Layer:** test support (`Class::Testkit` in `xtask/src/layers.rs`). A dev-dependency only, never + compiled into the product, and so usable even from the standalone crates (`cos`, `geom`, + `filters`, `crypt`, `arlington`) whose own tests need it. +- No panicking path of its own. The `or_skip!` macro expands its `assert!` into the caller, which + is test code. + +## Why it exists + +Every test that wanted an oracle used to probe for it by hand and decide on its own what to do when +it was missing. The common choice — print a line and return — reports as a **pass**, so a suite +with no oracles installed looked exactly like one in which every oracle agreed. + +That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/encryption.rs::qpdf_agrees` +had never executed on a runner, while `README.md` said saved output was "independently checked with +qpdf". For R2–R4 that test is the *only* check of owner-password authentication (Algorithm 7) that +does not go through our own code. + +## API + +```rust +// In a test: bind the oracle, or return after saying why it was skipped. +let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); +let out = qpdf.command().arg("--check").arg(&path).output()?; + +// Elsewhere (xtask), handle the absence yourself. +match pdfcraft_oracle::pdftotext() { + Ok(tool) => { /* tool.command() */ } + Err(absent) => bail!("{absent}"), +} +``` + +| Probe | Tool | Used for | +|---|---|---| +| `qpdf()` | qpdf | `--check` structural validation; the independent owner-password oracle | +| `pdftoppm()` | Poppler | page pixels, for comparing against our own raster output | +| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 against it) | + +Each returns `Result`. `Oracle::command()` hands back a fresh `Command`; +`Absent` renders as an actionable sentence naming the install command. + +## The gate + +`PDFCRAFT_ORACLES_REQUIRED` (any value, including `0` — the same convention as +`CRAFT_FONTS_REQUIRED`) turns absence from a skip into a failure. CI sets it on the runners that +install the tools, so a skip notice there becomes a red build. + +``` +$ cargo test -p pdfcraft-cos --test encryption qpdf_agrees +test result: ok. 1 passed # qpdf absent: skipped, with a notice on stderr + +$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test encryption qpdf_agrees +oracle `qpdf` is required but is not on PATH (program not found): PDFCRAFT_ORACLES_REQUIRED is +set, so this is a failure rather than a skip (apt install qpdf / brew install qpdf) +test result: FAILED. 1 failed +``` + +`or_skip!` expands to a `return`, so it belongs in a test returning `()`; a test returning `Result` +should match on the probe directly. + +## Still to do + +- CI installs only `qpdf`, the one tool the test job uses. The Poppler pixel comparison in + `crates/organize` stays `#[ignore]`d until a change turns it on and verifies it there. +- Oracles that are not external processes (hayro, `lopdf`) are ordinary dev-dependencies and are + not routed through this crate. diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs new file mode 100644 index 0000000000..440fb6e0f7 --- /dev/null +++ b/crates/oracle/src/lib.rs @@ -0,0 +1,246 @@ +//! External oracle processes: independent implementations PdfCraft checks its own output against. +//! +//! An oracle is only worth having when it runs. Before this crate every test that wanted one +//! probed for it by hand and decided on its own what to do when it was missing, and the common +//! choice — print a line and return — reports as a **pass**. A suite with no oracles installed +//! therefore looked exactly like one in which every oracle agreed, and on this repository's CI, +//! where none of them were installed, that is precisely what was happening: `qpdf` is the only +//! independent check of owner-password authentication for R2–R4, and it had never executed on a +//! runner while `README.md` said the output was "independently checked with qpdf". +//! +//! So the rule here is one line of policy: **a missing oracle is either a declared skip or a +//! failure, never a silent pass.** [`REQUIRED_VAR`] decides which. CI sets it, so absence breaks +//! the build; a contributor's laptop usually does not, so absence prints why and skips. +//! +//! ```no_run +//! # fn main() { +//! let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); +//! let out = qpdf.command().arg("--check").arg("f.pdf").output(); +//! # let _ = out; +//! # } +//! ``` +//! +//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency +//! only, never compiled into the product. + +use std::fmt; +use std::process::Command; + +/// Setting this environment variable to any value turns a missing oracle from a skip into a +/// failure. CI sets it on the runners that install the tools. +/// +/// Any value counts, including `0` — the same convention as `CRAFT_FONTS_REQUIRED`, which this +/// deliberately mirrors. The surprising half of that convention errs toward *requiring* an oracle, +/// which is the safe direction for a gate whose whole purpose is to stop absence passing quietly. +pub const REQUIRED_VAR: &str = "PDFCRAFT_ORACLES_REQUIRED"; + +/// An external program found on `PATH`, ready to run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Oracle { + program: &'static str, + version: String, +} + +impl Oracle { + /// A fresh [`Command`] for the program. Callers add their own arguments. + pub fn command(&self) -> Command { + Command::new(self.program) + } + + /// The program name, as it is looked up on `PATH`. + pub fn program(&self) -> &'static str { + self.program + } + + /// The first line the program printed when asked for its version, for test output. Empty when + /// it printed nothing recognisable; presence of the binary is what decides availability, not + /// whether its version could be parsed. + pub fn version(&self) -> &str { + &self.version + } +} + +/// An oracle that could not be used, and whether that is fatal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Absent { + program: &'static str, + cause: String, + required: bool, +} + +impl Absent { + /// Whether this absence must fail the run rather than skip it, i.e. whether [`REQUIRED_VAR`] + /// was set when the probe ran. + pub fn required(&self) -> bool { + self.required + } + + /// The program that was looked for. + pub fn program(&self) -> &'static str { + self.program + } + + /// How to install it, so a skip notice tells the reader what to do about it. + fn install_hint(program: &str) -> &'static str { + match program { + "qpdf" => "apt install qpdf / brew install qpdf", + "pdftoppm" | "pdftotext" => "apt install poppler-utils / brew install poppler", + _ => "install it and put it on PATH", + } + } +} + +impl fmt::Display for Absent { + /// Reads as an instruction either way: required absences are failures and name the gate that + /// made them one, optional absences name the install command. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.required { + write!( + f, + "oracle `{}` is required but {}: {} is set, so this is a failure rather than a skip ({})", + self.program, + self.cause, + REQUIRED_VAR, + Self::install_hint(self.program), + ) + } else { + write!( + f, + "skipping: oracle `{}` {} ({}); set {} to make this a failure instead", + self.program, + self.cause, + Self::install_hint(self.program), + REQUIRED_VAR, + ) + } + } +} + +/// Look for `program` on `PATH`, asking it for its version to confirm it runs. +/// +/// Presence is decided by whether the program could be started at all, not by its exit status: a +/// tool that runs but reports its version oddly is still an oracle. +pub fn find(program: &'static str, version_arg: &str) -> Result { + let required = std::env::var_os(REQUIRED_VAR).is_some(); + match Command::new(program).arg(version_arg).output() { + Ok(out) => { + // Poppler's tools print their version to stderr, qpdf to stdout. + let text = if out.stdout.is_empty() { &out.stderr } else { &out.stdout }; + let version = String::from_utf8_lossy(text).lines().next().unwrap_or_default().trim().to_owned(); + Ok(Oracle { program, version }) + } + Err(e) => Err(Absent { program, cause: format!("is not on PATH ({e})"), required }), + } +} + +/// qpdf: structural validation (`--check`) and the independent owner-password oracle. +pub fn qpdf() -> Result { + find("qpdf", "--version") +} + +/// Poppler's `pdftoppm`: renders a page to pixels, for comparing against our own raster output. +pub fn pdftoppm() -> Result { + find("pdftoppm", "-v") +} + +/// Poppler's `pdftotext`: the text-extraction oracle (`cargo xtask text-oracle` scores word F1 +/// against it over the corpus). +pub fn pdftotext() -> Result { + find("pdftotext", "-v") +} + +/// Binds an oracle, or returns from the calling test after saying on stderr why it was skipped. +/// +/// When [`REQUIRED_VAR`] is set the absence fails the test instead. The panic is deliberate and is +/// how a test reports failure; it expands into the caller, which is test code, so the workspace's +/// never-panic rule is not weakened — this crate itself contains no panicking path. +/// +/// The macro expands to a `return`, so it belongs in a test function returning `()`. A test that +/// returns `Result` should match on the probe itself. +#[macro_export] +macro_rules! or_skip { + ($probe:expr) => { + match $probe { + ::core::result::Result::Ok(oracle) => oracle, + ::core::result::Result::Err(absent) => { + let absent: $crate::Absent = absent; + ::core::assert!(!$crate::Absent::required(&absent), "{absent}"); + ::std::eprintln!("{absent}"); + return; + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The one behaviour the crate exists for: absence is never silent, and which of the two loud + /// forms it takes is decided by the environment variable. + #[test] + fn a_missing_oracle_is_fatal_exactly_when_it_is_required() { + let optional = Absent { program: "qpdf", cause: "is not on PATH".into(), required: false }; + let required = Absent { required: true, ..optional.clone() }; + + assert!(!optional.required()); + assert!(required.required()); + + // Both say what is wrong and how to fix it; only the required one calls itself a failure. + let (opt, req) = (optional.to_string(), required.to_string()); + assert!(opt.contains("skipping"), "{opt}"); + assert!(opt.contains("apt install qpdf"), "{opt}"); + assert!(opt.contains(REQUIRED_VAR), "{opt}"); + assert!(req.contains("required"), "{req}"); + assert!(req.contains("failure"), "{req}"); + assert!(!req.contains("skipping"), "{req}"); + } + + /// A tool that does not exist is reported as absent rather than hanging or panicking, and the + /// reason names the program. + #[test] + fn a_program_that_does_not_exist_is_absent_with_a_reason() { + let Err(absent) = find("pdfcraft-no-such-oracle-binary", "--version") else { + panic!("a nonexistent program must not be reported as present"); + }; + assert_eq!(absent.program(), "pdfcraft-no-such-oracle-binary"); + let text = absent.to_string(); + assert!(text.contains("pdfcraft-no-such-oracle-binary"), "{text}"); + assert!(text.contains("not on PATH"), "{text}"); + } + + /// Hostile and odd program names must not panic, hang or be mistaken for a working oracle. + #[test] + fn hostile_program_names_are_absent_not_a_panic() { + for name in ["", " ", "\0bad", "../../etc/passwd", &"x".repeat(4096)] { + // `find` takes a 'static str; leak the generated ones, which is fine in a test. + let name: &'static str = Box::leak(name.to_owned().into_boxed_str()); + let found = find(name, "--version"); + if let Ok(o) = found { + // Nothing by these names should run; if one somehow does, it must still be sane. + assert_eq!(o.program(), name); + } + } + } + + /// The install hint is specific per tool, so a skip notice is actionable. + #[test] + fn every_known_oracle_has_an_install_hint() { + for p in ["qpdf", "pdftoppm", "pdftotext"] { + let hint = Absent::install_hint(p); + assert!(hint.contains("install"), "{p}: {hint}"); + assert_ne!(hint, Absent::install_hint("something-else"), "{p} needs its own hint"); + } + } + + /// Probing is read-only and repeatable: asking twice gives the same answer. + #[test] + fn probing_is_stable() { + let a = qpdf().map(|o| o.program()); + let b = qpdf().map(|o| o.program()); + assert_eq!(a.is_ok(), b.is_ok()); + if let (Ok(a), Ok(b)) = (a, b) { + assert_eq!(a, b); + } + } +} diff --git a/crates/organize/Cargo.toml b/crates/organize/Cargo.toml index e759a322d1..78507197a5 100644 --- a/crates/organize/Cargo.toml +++ b/crates/organize/Cargo.toml @@ -14,4 +14,5 @@ pdfcraft-cos = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-oracle = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index e07af72a1c..a612587d26 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2025,9 +2025,12 @@ fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once"); } +// Stays ignored until CI installs Poppler: the pixel comparison has never run on a runner, so +// un-ignoring it here would turn an unverified test loose on everyone. See crates/oracle. #[test] #[ignore = "requires the external Poppler pdftoppm oracle"] fn extract_type3_shared_xobjects_matches_poppler_pixels() { + let pdftoppm = pdfcraft_oracle::or_skip!(pdfcraft_oracle::pdftoppm()); let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let fixtures = [ @@ -2042,7 +2045,7 @@ fn extract_type3_shared_xobjects_matches_poppler_pixels() { let render = |doc: &Document, name: &str| { let path = dir.join(format!("{i}-{name}.pdf")); std::fs::write(&path, write_full(doc, &SaveOptions::default()).unwrap()).unwrap(); - let output = std::process::Command::new("pdftoppm").args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap(); + let output = pdftoppm.command().args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap(); assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); output.stdout diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 359a71e9a6..3f56e207ad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -335,7 +335,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output"] -notes = "skipped when qpdf is absent" +notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle)" [[feature]] id = "core.deterministic-save" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index def0fe755c..1171dfc568 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,6 +6,7 @@ publish = false license.workspace = true [dependencies] +pdfcraft-oracle = { workspace = true } anyhow = "1" lopdf = "0.45" png = "0.18" diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 4406a957c4..cf3d164ce8 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -200,9 +200,10 @@ pub fn check(args: &[String]) -> anyhow::Result<()> { /// median. Plan target (M2): median ≥ 0.97. pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let limit: usize = args.iter().position(|a| a == "--limit").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(usize::MAX); - if Command::new("pdftotext").arg("-v").output().is_err() { - bail!("pdftotext (poppler) not found; install it to run the text oracle (brew install poppler / apt install poppler-utils)"); - } + let pdftotext = match pdfcraft_oracle::pdftotext() { + Ok(o) => o, + Err(absent) => bail!("{absent}"), + }; run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; let corpus = root().join("corpus/pdfjs/test/pdfs"); let mut files: Vec = @@ -213,7 +214,7 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let mut worst: Vec<(f64, String)> = Vec::new(); for f in files.iter().take(limit) { let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20); - let theirs = run_capture(Command::new("pdftotext").arg("-q").arg(f).arg("-"), 20); + let theirs = run_capture(pdftotext.command().arg("-q").arg(f).arg("-"), 20); let (Some(ours), Some(theirs)) = (ours, theirs) else { continue }; let (a, b) = (words(&ours), words(&theirs)); if b.len() < 20 { From 50629eb6bf82d55f8761834fdf204b80edb88601 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 13:49:39 -0700 Subject: [PATCH 003/207] feat(testkit): one way to write a synthetic PDF fixture 55 files hand-rolled the same serialiser -- number the objects, record each one's offset, emit a classic cross-reference table and a trailer -- 111 times over. The object bodies are properly the business of the crate under test; the envelope around them is not, and every copy is a place for a fixture to be quietly wrong in a way that makes a test prove less than it appears to. `pdfcraft-testkit` holds that envelope. It has no workspace dependencies on purpose, so the crates at the bottom of the layering can use it without a cycle, and it returns bytes rather than a parsed document for the same reason. The builder is byte-identical to the serialiser it replaces, asserted against a verbatim copy of the old code across versions and trailer variations, because a migration that changes a fixture's bytes silently changes what the test using it proves. Watched failing by breaking the free-entry generation: three tests caught it. The one intended difference is that an empty stream dictionary gives `<< /Length N >>` rather than a doubled space. Migrates cos, measure and redact: 111 copies in 55 files becomes 108 in 53. The rest are follow-ups rather than one sweep -- ui-egui alone is 27 of those files and is the most contested area in the repository. Some copies should stay: a test about a malformed cross-reference table needs to write its own bytes. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 7 + Cargo.toml | 1 + crates/cos/Cargo.toml | 1 + crates/cos/tests/writer.rs | 33 ++--- crates/measure/Cargo.toml | 3 + crates/measure/src/tests.rs | 28 ++-- crates/redact/Cargo.toml | 1 + crates/redact/src/tests.rs | 26 +--- crates/testkit/Cargo.toml | 11 ++ crates/testkit/README.md | 57 ++++++++ crates/testkit/src/lib.rs | 253 ++++++++++++++++++++++++++++++++++++ 11 files changed, 356 insertions(+), 65 deletions(-) create mode 100644 crates/testkit/Cargo.toml create mode 100644 crates/testkit/README.md create mode 100644 crates/testkit/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2984a688f6..ae648efe69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3984,6 +3984,7 @@ dependencies = [ "pdfcraft-crypt", "pdfcraft-filters", "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -4127,6 +4128,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] @@ -4215,6 +4217,7 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", "thiserror 2.0.21", ] @@ -4271,6 +4274,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.8.0" + [[package]] name = "pdfcraft-ui-egui" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 6940b9acbb..84b2ed1f20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } pdfcraft-js = { path = "crates/js", version = "0.8.0" } pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index 1be1561e63..9ef9126ca8 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -15,6 +15,7 @@ thiserror = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } +pdfcraft-testkit = { workspace = true } serde_json = { workspace = true } proptest = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 87462f4678..a7f071e8e7 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -4,34 +4,23 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; /// Four pages, a shared font, document info with a non-ASCII title. fn fixture() -> Vec { - let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..4).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); - objs.push(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))); - objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + let mut pdf = Pdf::new() + .version("1.4") + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); for i in 0..4 { - objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); - let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); - objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + pdf = pdf + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)) + .stream("", format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1)); } - objs.push("<< /Title (Caf\\351 \\(draft\\)) >>".into()); - let mut out = b"%PDF-1.4\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice( - format!("trailer\n<< /Size {} /Root 1 0 R /Info {} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()).as_bytes(), - ); - out + let info = pdf.next_obj(); + pdf.obj("<< /Title (Caf\\351 \\(draft\\)) >>").trailer_entry(&format!("/Info {info} 0 R")).build() } fn title(doc: &Document) -> Vec { diff --git a/crates/measure/Cargo.toml b/crates/measure/Cargo.toml index eb2de9422b..66f071c922 100644 --- a/crates/measure/Cargo.toml +++ b/crates/measure/Cargo.toml @@ -15,3 +15,6 @@ pdfcraft-content = { workspace = true } pdfcraft-annot = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 20a730bed9..6af32632fa 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -1,30 +1,18 @@ use super::*; use pdfcraft_cos::{SaveOptions, write_full}; +use pdfcraft_testkit::Pdf; use std::sync::Arc; fn fixture(content: &str, attrs: &str, extra: &[&str]) -> Document { fixture_with("4 0 R", content, attrs, extra) } fn fixture_with(contents: &str, content: &str, attrs: &str, extra: &[&str]) -> Document { - let mut objects = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), - format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - objects.extend(extra.iter().map(|s| s.to_string())); - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objects.iter().enumerate() { - offsets.push(bytes.len()); - bytes.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); - for o in offsets { - bytes.extend(format!("{o:010} 00000 n \n").as_bytes()); - } - bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); - Document::open(Arc::new(bytes)).unwrap() + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /XObject << /Nested 5 0 R >> >> {attrs} >>")) + .stream("", content) + .objs(extra); + Document::open(Arc::new(pdf.build())).unwrap() } fn close(a: f64, b: f64) { assert!((a - b).abs() < 1e-8, "{a} != {b}"); diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 4aee20bfc4..214c07f80e 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -19,3 +19,4 @@ pdfcraft-annot = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 7fcbc43057..09723b7630 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2,32 +2,12 @@ use std::sync::Arc; use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::{Pdf, stream_obj as stream}; use super::*; -fn stream(dict: &str, data: &[u8]) -> Vec { - let mut v = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); - v.extend_from_slice(data); - v.extend_from_slice(b"\nendstream"); - v -} - fn pdf(objs: Vec>) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - out.extend_from_slice(o); - out.extend_from_slice(b"\nendobj\n"); - } - let x = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); - Document::open(Arc::new(out)).unwrap() + Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap() } /// A font where every glyph is 500 units wide (so a 10 pt glyph advances 5 pt). @@ -252,7 +232,7 @@ fn vertical_writing_text_under_a_mark_fails_closed() { #[test] fn images_are_removed_or_have_their_pixels_cleared() { // Image 1 is fully covered; image 2 (4×1 gray, all white) is half covered. - let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 255, 255, 255]); + let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]); let src = b"q 10 0 0 10 10 10 cm /Im1 Do Q q 40 0 0 10 100 100 cm /Im2 Do Q"; let mut doc = one_page(src, "/XObject << /Im1 7 0 R /Im2 7 0 R >>", vec![img]); mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0], [95.0, 95.0, 120.0, 115.0]], ""); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 0000000000..4237f341b9 --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pdfcraft-testkit" +description = "Synthetic PDF fixtures for PdfCraft's tests: one way to write the bytes, so the tests differ only where they mean to" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/README.md b/crates/testkit/README.md new file mode 100644 index 0000000000..44388b1b23 --- /dev/null +++ b/crates/testkit/README.md @@ -0,0 +1,57 @@ +# pdfcraft-testkit + +Synthetic PDF fixtures: **one way to write the bytes, so tests differ only where they mean to.** + +- **Layer:** test support (`Class::Testkit` in `xtask/src/layers.rs`). A dev-dependency only, never + compiled into the product. +- **No workspace dependencies**, deliberately. The crates at the bottom of the layering (`cos`, + `geom`, `filters`, `crypt`, `arlington`) can use it in their own tests without a cycle, which is + also why it hands back bytes rather than a parsed `Document`. + +## Why it exists + +55 files hand-rolled the same serialiser — number the objects, record each one's offset, emit a +classic cross-reference table and a trailer — **111 times over**. The object *bodies* are properly +the business of the crate under test. The envelope around them is not, and every copy is a place +for a fixture to be quietly wrong in a way that makes a test prove less than it appears to. + +## API + +```rust +use pdfcraft_testkit::Pdf; + +let bytes = Pdf::new() + .version("1.4") // default 1.7 + .obj("<< /Type /Catalog /Pages 2 0 R >>") // becomes 1 0 R + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", b"BT /F1 12 Tf (hi) Tj ET") // /Length computed + .trailer_entry("/Info 5 0 R") + .build(); +``` + +Objects are numbered from 1 in the order they are added, all at generation 0; the trailer always +names `1 0 R` as `/Root` and carries the right `/Size`. `next_obj()` gives the number the next +object will get, for forward references. `stream_obj(dict, data)` builds a stream body on its own, +for callers that assemble their object list first. + +## Byte-identical by construction + +`the_builder_is_byte_identical_to_the_serialiser_it_replaces` keeps a verbatim copy of the +hand-rolled serialiser and asserts the builder agrees with it, across versions and trailer +variations. Migrating a crate must not change a single byte of its fixtures — otherwise the test +that fixture feeds silently starts testing something else. + +The one intended difference: an empty stream dictionary gives `<< /Length N >>` rather than the +doubled space `<< /Length N >>` that some copies produced. Parsers accept both. + +## Migration status + +Three crates are migrated (`cos`, `measure`, `redact`), taking the count from 111 copies in 55 +files to **108 in 53**. The rest are follow-ups, deliberately not done in one sweep: `ui-egui` +alone accounts for 27 of those files and is the most contested area in the repository, so a single +large mechanical change there would rot before it landed. + +Not every copy should move. A test that is *about* a malformed or unusual cross-reference table +needs to write its own bytes; this builder is for the fixtures that are merely scaffolding for +something else. diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 0000000000..c9f5e55ef2 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,253 @@ +//! Synthetic PDF fixtures: one way to write the bytes, so tests differ only where they mean to. +//! +//! 55 files across the workspace hand-rolled the same serialiser — number the objects, record each +//! one's offset, emit a classic cross-reference table and a trailer — 111 times over. The object +//! *bodies* are properly the business of the crate under test; the envelope around them is not, +//! and every copy of it is a place for a fixture to be subtly wrong in a way that makes a test +//! prove less than it looks like it proves. +//! +//! [`Pdf`] writes that envelope. It is deliberately dependency-free, so the crates at the bottom of +//! the layering (`cos`, `geom`, `filters`, `crypt`) can use it in their own tests without a cycle, +//! and it hands back bytes rather than a parsed document for the same reason. +//! +//! ``` +//! use pdfcraft_testkit::Pdf; +//! let bytes = Pdf::new() +//! .obj("<< /Type /Catalog /Pages 2 0 R >>") +//! .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") +//! .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>") +//! .stream("", b"BT /F1 12 Tf (hi) Tj ET") +//! .build(); +//! assert!(bytes.starts_with(b"%PDF-1.7\n")); +//! assert!(bytes.ends_with(b"%%EOF\n")); +//! ``` +//! +//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency +//! only, never compiled into the product. + +/// A small PDF with a classic cross-reference table, built one object at a time. +/// +/// Objects are numbered from 1 in the order they are added, all at generation 0, so the first +/// object added is `1 0 R`. The trailer names `1 0 R` as `/Root`, which is what every caller of +/// this builder wants; [`Pdf::trailer_entry`] adds the rest. +#[derive(Debug, Clone)] +pub struct Pdf { + version: String, + objects: Vec>, + trailer: Vec, +} + +impl Default for Pdf { + fn default() -> Self { + Self::new() + } +} + +impl Pdf { + /// An empty PDF 1.7 file. Add objects with [`Pdf::obj`]. + pub fn new() -> Self { + Self { version: "1.7".to_owned(), objects: Vec::new(), trailer: Vec::new() } + } + + /// Set the header version, as it appears after `%PDF-` (for example `"1.4"`). + #[must_use] + pub fn version(mut self, version: &str) -> Self { + self.version = version.to_owned(); + self + } + + /// Append one object body — everything between `N 0 obj` and `endobj`. + #[must_use] + pub fn obj(mut self, body: impl AsRef<[u8]>) -> Self { + self.objects.push(body.as_ref().to_vec()); + self + } + + /// Append several object bodies, in order. + #[must_use] + pub fn objs, T: AsRef<[u8]>>(mut self, bodies: I) -> Self { + self.objects.extend(bodies.into_iter().map(|b| b.as_ref().to_vec())); + self + } + + /// Append a stream object: `dict` holds any entries besides `/Length`, which is computed. + #[must_use] + pub fn stream(self, dict: &str, data: impl AsRef<[u8]>) -> Self { + self.obj(stream_obj(dict, data)) + } + + /// Add an entry to the trailer dictionary, such as `"/Info 9 0 R"`. `/Size` and `/Root` are + /// always written. + #[must_use] + pub fn trailer_entry(mut self, entry: &str) -> Self { + self.trailer.push(entry.to_owned()); + self + } + + /// The number the next object added will get, so a caller can refer forward to it. + /// + /// ``` + /// # use pdfcraft_testkit::Pdf; + /// let mut p = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>"); + /// assert_eq!(p.next_obj(), 2); + /// ``` + pub fn next_obj(&self) -> usize { + self.objects.len().saturating_add(1) + } + + /// Serialise the file. + pub fn build(&self) -> Vec { + let mut out = format!("%PDF-{}\n", self.version).into_bytes(); + let mut offsets = Vec::with_capacity(self.objects.len()); + for (i, body) in self.objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i.saturating_add(1)).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let startxref = out.len(); + let size = self.objects.len().saturating_add(1); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + let extra = self.trailer.iter().map(|e| format!(" {e}")).collect::(); + out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R{extra} >>\nstartxref\n{startxref}\n%%EOF\n").as_bytes()); + out + } +} + +/// A stream object body with `/Length` filled in, for callers assembling object lists themselves. +/// +/// `dict` holds any entries besides `/Length`; an empty one gives `<< /Length N >>` rather than +/// the doubled space the hand-rolled copies produced, which parsers accept but readers trip over. +pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec { + let data = data.as_ref(); + let dict = dict.trim(); + let sep = if dict.is_empty() { "" } else { " " }; + let mut out = format!("<<{sep}{dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The serialiser the workspace had 111 copies of, kept here verbatim as the thing [`Pdf`] has + /// to agree with. Migrating a crate to the builder must not change a single byte of its + /// fixtures, or the test that fixture feeds silently starts testing something else. + fn hand_rolled(version: &str, objs: &[Vec], trailer_extra: &str) -> Vec { + let mut out = format!("%PDF-{version}\n").into_bytes(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{trailer_extra} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + fn sample_objects() -> Vec> { + vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + stream_obj("", b"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"), + ] + } + + #[test] + fn the_builder_is_byte_identical_to_the_serialiser_it_replaces() { + let objs = sample_objects(); + for version in ["1.4", "1.7", "2.0"] { + let built = Pdf::new().version(version).objs(objs.clone()).build(); + assert_eq!(built, hand_rolled(version, &objs, ""), "version {version}"); + } + // ...including the trailer entries the copies varied on. + let built = Pdf::new().objs(objs.clone()).trailer_entry("/Info 5 0 R").build(); + assert_eq!(built, hand_rolled("1.7", &objs, " /Info 5 0 R")); + } + + /// Offsets must point at the `N 0 obj` that starts each object, counted from the file's first + /// byte. A fixture whose table is wrong tests the repair path, not the path it meant to. + #[test] + fn every_offset_in_the_table_points_at_its_object() { + let bytes = Pdf::new().objs(sample_objects()).build(); + let text = String::from_utf8_lossy(&bytes); + let table = text.split("xref\n0 ").nth(1).expect("an xref table"); + let rows: Vec<&str> = table.lines().skip(1).take_while(|l| l.ends_with(" n ") || l.ends_with(" f ")).collect(); + assert_eq!(rows.len(), 5, "free entry plus four objects: {rows:?}"); + for (i, row) in rows.iter().enumerate().skip(1) { + let offset: usize = row.split_whitespace().next().and_then(|n| n.parse().ok()).expect("an offset"); + let expected = format!("{i} 0 obj\n"); + assert!(bytes.get(offset..).is_some_and(|rest| rest.starts_with(expected.as_bytes())), "object {i} at {offset}"); + } + } + + /// `/Size` is one more than the object count (object 0 is the free head), and `startxref` + /// points at the table. + #[test] + fn the_trailer_agrees_with_the_body() { + let bytes = Pdf::new().objs(sample_objects()).build(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Size 5"), "{text}"); + let startxref: usize = text.rsplit("startxref\n").next().and_then(|t| t.lines().next()).and_then(|n| n.parse().ok()).expect("startxref"); + assert!(bytes.get(startxref..).is_some_and(|rest| rest.starts_with(b"xref\n")), "startxref {startxref}"); + } + + #[test] + fn stream_length_counts_the_data_not_the_keywords() { + let obj = stream_obj("/Filter /FlateDecode", b"abc"); + assert_eq!(obj, b"<< /Filter /FlateDecode /Length 3 >>\nstream\nabc\nendstream".to_vec()); + // Binary data, including NUL and bytes that are not UTF-8, survives untouched. + let data = [0u8, 0xFF, b'\n', 0x80]; + let obj = stream_obj("", data); + assert!(obj.ends_with(b"stream\n\0\xff\n\x80\nendstream"), "{obj:?}"); + // An empty dict gives one space, not two. + assert!(obj.starts_with(b"<< /Length 4 >>"), "{obj:?}"); + assert_eq!(stream_obj(" /Type /XObject ", b""), stream_obj("/Type /XObject", b"")); + } + + /// An empty file is still a parseable shape rather than something that panics or truncates. + #[test] + fn a_file_with_no_objects_is_still_well_formed() { + let bytes = Pdf::new().build(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.7\n"), "{text}"); + assert!(text.contains("xref\n0 1\n0000000000 65535 f \n"), "{text}"); + assert!(text.contains("/Size 1 /Root 1 0 R"), "{text}"); + assert!(text.ends_with("%%EOF\n"), "{text}"); + } + + #[test] + fn next_obj_tracks_what_has_been_added() { + let p = Pdf::new(); + assert_eq!(p.next_obj(), 1); + let p = p.obj("<< >>").obj("<< >>"); + assert_eq!(p.next_obj(), 3); + // Forward references are the reason this exists: the page can name a stream not yet added. + let n = p.next_obj(); + let bytes = p.obj(format!("<< /Contents {n} 0 R >>")).build(); + assert!(String::from_utf8_lossy(&bytes).contains("/Contents 3 0 R")); + } + + /// Hostile and awkward object bodies must not panic or corrupt the offsets. + #[test] + fn odd_object_bodies_keep_the_table_honest() { + let bodies: Vec> = vec![Vec::new(), b"\n".to_vec(), vec![0u8; 1024], "<< /T (\u{1F4A5}) >>".as_bytes().to_vec(), b"endobj".to_vec()]; + let bytes = Pdf::new().objs(bodies.clone()).build(); + assert_eq!(bytes, hand_rolled("1.7", &bodies, "")); + // The offsets still land on their objects even when a body contains `endobj` itself. + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Size 6"), "{text}"); + } +} From f988cf87edb1ed3cbc782321ded8a91ef694563b Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 14:20:29 -0700 Subject: [PATCH 004/207] feat(oracle): measure rendering against Poppler, and turn the pixel test on Gap 12 of docs/gaps.md: until rendering is measured, every claim about its quality is a judgement. Text extraction had an oracle and written output had qpdf; rendering had nothing. `cargo xtask render-oracle` renders page 1 of every corpus file with our CLI and with Poppler's pdftoppm and reports how far apart they are. The metric is the point. A raw pixel comparison is useless here -- two correct renderers disagree on every glyph edge, so it calls a perfect page badly wrong and says nothing about whether a shading is missing. `pixels::compare` counts a pixel as differing only when it differs from every pixel in the other image's 3x3 neighbourhood by more than a tolerance, in both directions, taking the worse. A one-pixel shift is forgiven; absent or miscoloured content is not. First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean 2.16%, p90 5.35%, 25 within 0.1%. It reports rather than gates -- the corpus is not on CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. The worst results cluster on JBIG2 `bitmap-*` files and on ZapfDingbats, which aims the renderer work at something specific for the first time. 15 files could not be compared because the two renderers round the page box to different pixel sizes; they are reported as unmeasurable rather than scored. Verified against the knob's bounds end to end: tolerance 255 scores every file 0.00%, tolerance 0 takes the median to 0.95% and p90 to 26.36%. Also turns on `extract_type3_shared_xobjects_matches_poppler_pixels`, which had never run anywhere. Poppler is now installed on the Linux runner, and the test is version-robust by construction -- both sides of its comparison go through the same pdftoppm, so it is about our two documents rather than about Poppler's output. Verified in all three states: runs and passes with pdftoppm present, skips without it, fails without it when PDFCRAFT_ORACLES_REQUIRED is set. `run_capture` keeps its exact behaviour, including leaving the buffer empty on non-UTF-8 output; the byte-oriented capture an image needs is a second entry point on the same timeout logic. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 +- crates/oracle/README.md | 23 +- crates/oracle/src/lib.rs | 2 + crates/oracle/src/pixels.rs | 428 +++++++++++++++++++++++++++++++++++ crates/organize/src/tests.rs | 6 +- docs/gaps.md | 23 +- xtask/src/gates.rs | 118 +++++++++- xtask/src/main.rs | 5 + 8 files changed, 594 insertions(+), 16 deletions(-) create mode 100644 crates/oracle/src/pixels.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ac7bdeaf4..388b44f032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,11 @@ jobs: sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev \ - qpdf + qpdf poppler-utils # The independent oracles (crates/oracle) are only worth having when they run. With this # set, a missing one fails the job instead of printing a skip notice that reads as a pass. - # Only the tools the job actually uses are installed above; a test that needs another one - # (Poppler, for the pixel comparison in crates/organize) stays `#[ignore]`d until then. + # Only the tools the job actually uses are installed above. - name: Require the oracles (Linux) if: runner.os == 'Linux' run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV" diff --git a/crates/oracle/README.md b/crates/oracle/README.md index d02f5b5f9f..c94e080638 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -62,9 +62,28 @@ test result: FAILED. 1 failed `or_skip!` expands to a `return`, so it belongs in a test returning `()`; a test returning `Result` should match on the probe directly. +## Comparing pixels (`pixels`) + +Rendering needs more than "did the tool run". A raw pixel-for-pixel difference is useless here: +two correct renderers disagree on every glyph edge, so a strict comparison calls a perfect page +badly wrong and tells you nothing about whether a shading is missing. + +`pixels::compare` counts a pixel as differing only when it differs from **every** pixel in the +other image's 3x3 neighbourhood by more than a tolerance. A one-pixel shift or a differently +weighted edge finds a near match next door and is forgiven; a glyph that is not there, or a region +painted the wrong colour, has no near match and is counted. The test runs in both directions and +reports the worse result, so neither argument order flatters the blurrier renderer. + +`decode_pgm` reads what `pdftoppm -gray` writes; `decode_pam_gray` reads what +`pdfcraft-cli render --out x.pam` writes, compositing premultiplied alpha over white first so a +transparent margin is not scored as black. + +`cargo xtask render-oracle` drives it over the corpus. It reports; it does not gate. + ## Still to do -- CI installs only `qpdf`, the one tool the test job uses. The Poppler pixel comparison in - `crates/organize` stays `#[ignore]`d until a change turns it on and verifies it there. +- Acrobat itself as the reference, rather than other open renderers. - Oracles that are not external processes (hayro, `lopdf`) are ordinary dev-dependencies and are not routed through this crate. +- Two renderers sometimes round the same page box to different pixel sizes, and those files are + counted as unmeasurable rather than compared. diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs index 440fb6e0f7..e9e7ae372e 100644 --- a/crates/oracle/src/lib.rs +++ b/crates/oracle/src/lib.rs @@ -23,6 +23,8 @@ //! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency //! only, never compiled into the product. +pub mod pixels; + use std::fmt; use std::process::Command; diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs new file mode 100644 index 0000000000..7365e37eaf --- /dev/null +++ b/crates/oracle/src/pixels.rs @@ -0,0 +1,428 @@ +//! Comparing a rendered page against an independent renderer's pixels. +//! +//! A raw pixel-for-pixel difference is useless for this. Two correct renderers disagree on every +//! glyph edge — antialiasing coverage, hinting, and where a subpixel-positioned stem lands — so a +//! strict comparison reports a page as badly wrong when it is perfect, and the number it produces +//! tells you nothing about whether a shading is missing or a glyph is the wrong one. +//! +//! [`compare`] uses the usual remedy: a pixel counts as differing only when it differs from +//! **every** pixel in the other image's 3×3 neighbourhood by more than `tolerance`. A one-pixel +//! shift in a stem, or a differently-weighted edge, finds a near match next door and is forgiven. +//! A glyph that is not there, a shading that was not drawn, or a region painted the wrong colour +//! has no near match anywhere nearby and is counted. +//! +//! The test is run in both directions and the worse result reported, because the one-directional +//! form is asymmetric in a way that flatters whichever image is blurrier: thin content missing +//! from A is forgiven when scanning A against B's neighbourhood if B's stroke happens to be wide. + +/// A single-channel (grayscale) image borrowed for comparison. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Gray<'a> { + width: usize, + height: usize, + pixels: &'a [u8], +} + +impl<'a> Gray<'a> { + /// Borrow `pixels` as a `width` × `height` image, or `None` when the buffer is not exactly + /// that size — a truncated decode must not be compared as if it were a short page. + pub fn new(width: usize, height: usize, pixels: &'a [u8]) -> Option { + (width.checked_mul(height)? == pixels.len()).then_some(Self { width, height, pixels }) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + /// The pixel at `(x, y)`, or `None` outside the image. + pub fn get(&self, x: usize, y: usize) -> Option { + if x >= self.width || y >= self.height { + return None; + } + self.pixels.get(y.checked_mul(self.width)?.checked_add(x)?).copied() + } +} + +/// Why two images could not be compared. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SizeMismatch { + pub a: (usize, usize), + pub b: (usize, usize), +} + +impl std::fmt::Display for SizeMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "cannot compare a {}x{} image with a {}x{} one; render both at the same scale", self.a.0, self.a.1, self.b.0, self.b.1) + } +} + +/// How far apart two renderings are. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Diff { + /// Pixels with no near match in the other image's neighbourhood, in the worse direction. + pub differing: usize, + /// Pixels in the image. + pub total: usize, + /// The largest single-pixel difference found, before the neighbourhood is considered. A high + /// value with a low [`Diff::fraction`] is the signature of an edge-only disagreement. + pub max_delta: u8, +} + +impl Diff { + /// The share of pixels with no near match, in `0.0..=1.0`. Zero for an empty image: nothing + /// disagreed because there was nothing to disagree about. + pub fn fraction(&self) -> f64 { + if self.total == 0 { + return 0.0; + } + // total is non-zero, and differing <= total, so this is finite and in 0..=1. + self.differing as f64 / self.total as f64 + } + + /// `fraction` as a percentage, for reports. + pub fn percent(&self) -> f64 { + self.fraction() * 100.0 + } +} + +/// Compare two grayscale renderings of the same page at the same size. +/// +/// `tolerance` is the per-channel difference tolerated outright; 8 or so absorbs ordinary +/// antialiasing weight differences without hiding a wrong colour. +pub fn compare(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> Result { + if a.width != b.width || a.height != b.height { + return Err(SizeMismatch { a: (a.width, a.height), b: (b.width, b.height) }); + } + let max_delta = a.pixels.iter().zip(b.pixels).map(|(&p, &q)| p.abs_diff(q)).max().unwrap_or(0); + let forward = unmatched(a, b, tolerance); + let backward = unmatched(b, a, tolerance); + Ok(Diff { differing: forward.max(backward), total: a.pixels.len(), max_delta }) +} + +/// Pixels of `a` with no pixel within `tolerance` anywhere in `b`'s surrounding 3x3. +fn unmatched(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> usize { + let mut count = 0usize; + for y in 0..a.height { + for x in 0..a.width { + let Some(p) = a.get(x, y) else { continue }; + let mut matched = false; + // Saturating bounds keep the window inside the image at the edges; `get` rejects the + // rest, so no index is ever derived from an unchecked subtraction. + for ny in y.saturating_sub(1)..=y.saturating_add(1) { + for nx in x.saturating_sub(1)..=x.saturating_add(1) { + if b.get(nx, ny).is_some_and(|q| p.abs_diff(q) <= tolerance) { + matched = true; + break; + } + } + if matched { + break; + } + } + if !matched { + count = count.saturating_add(1); + } + } + } + count +} + +/// Decode a binary netpbm grayscale image (`P5`), which is what `pdftoppm -gray` writes. +/// +/// Returns the dimensions and the pixel bytes. Only the maxval 255 form is accepted: a 16-bit +/// image would otherwise be read as twice as many 8-bit pixels and compared against nonsense. +pub fn decode_pgm(bytes: &[u8]) -> Option<(usize, usize, Vec)> { + let mut fields = Vec::new(); + let mut rest = bytes.strip_prefix(b"P5")?; + // Three whitespace-separated fields follow the magic: width, height, maxval. Comments run + // from `#` to end of line and may appear between any of them. + while fields.len() < 3 { + let skip = rest.iter().position(|b| !b.is_ascii_whitespace())?; + rest = rest.get(skip..)?; + if rest.first() == Some(&b'#') { + let end = rest.iter().position(|&b| b == b'\n')?; + rest = rest.get(end.checked_add(1)?..)?; + continue; + } + let end = rest.iter().position(u8::is_ascii_whitespace)?; + let field = rest.get(..end)?; + fields.push(std::str::from_utf8(field).ok()?.parse::().ok()?); + rest = rest.get(end..)?; + } + let (&width, &height, &maxval) = (fields.first()?, fields.get(1)?, fields.get(2)?); + if maxval != 255 { + return None; + } + // Exactly one whitespace byte separates the header from the data. + let pixels = rest.get(1..)?; + let wanted = width.checked_mul(height)?; + (pixels.len() >= wanted).then(|| (width, height, pixels.get(..wanted).unwrap_or_default().to_vec())) +} + +/// Decode the netpbm `P7` RGB_ALPHA image `pdfcraft-cli render --out x.pam` writes, flattening it +/// to grayscale the way a viewer would show it: composited over white, then luminance. +/// +/// The alpha is premultiplied, which is why the composite is `channel + white * (1 - alpha)` +/// rather than the usual `mix`. A page is normally opaque, but a transparent one must not come out +/// darker here than it looks on screen, or the comparison blames the renderer for the harness. +pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> { + let header_end = find(bytes, b"ENDHDR\n")?.checked_add(b"ENDHDR\n".len())?; + let header = std::str::from_utf8(bytes.get(..header_end)?).ok()?; + if !header.starts_with("P7") { + return None; + } + let field = |name: &str| -> Option { header.lines().find_map(|l| l.strip_prefix(name)?.trim().parse::().ok()) }; + let (width, height) = (field("WIDTH")?, field("HEIGHT")?); + if field("DEPTH")? != 4 || field("MAXVAL")? != 255 || !header.contains("TUPLTYPE RGB_ALPHA") { + return None; + } + let data = bytes.get(header_end..)?; + let wanted = width.checked_mul(height)?.checked_mul(4)?; + if data.len() < wanted { + return None; + } + let mut out = Vec::with_capacity(width.checked_mul(height)?); + for px in data.get(..wanted)?.chunks_exact(4) { + let (&r, &g, &b, &a) = (px.first()?, px.get(1)?, px.get(2)?, px.get(3)?); + // Composite premultiplied RGB over white, in integer arithmetic that cannot overflow: + // each term is at most 255, and the sum is at most 255. + let over_white = |c: u8| u32::from(c).saturating_add(255u32.saturating_sub(u32::from(a))).min(255); + let (r, g, b) = (over_white(r), over_white(g), over_white(b)); + // Rec. 601 luma, the usual grayscale for this, rounded. Each term is at most 255*587, so + // the sum cannot overflow a u32. + let luma = (r * 299 + g * 587 + b * 114 + 500) / 1000; + out.push(u8::try_from(luma.min(255)).unwrap_or(255)); + } + Some((width, height, out)) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gray(w: usize, h: usize, fill: u8) -> Vec { + vec![fill; w * h] + } + + fn pam(w: usize, h: usize, px: &[u8]) -> Vec { + let mut out = format!("P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n").into_bytes(); + out.extend_from_slice(px); + out + } + + #[test] + fn pam_flattens_to_the_grey_a_viewer_would_show() { + // Opaque white, opaque black, opaque mid-grey. + let px = [255, 255, 255, 255, 0, 0, 0, 255, 128, 128, 128, 255]; + let (w, h, g) = decode_pam_gray(&pam(3, 1, &px)).expect("a P7 image"); + assert_eq!((w, h), (3, 1)); + assert_eq!(g, vec![255, 0, 128]); + } + + /// A fully transparent pixel is white on screen, not black. Getting this wrong would make the + /// harness report a blank margin as a difference on every page that has one. + #[test] + fn transparent_pixels_composite_over_white() { + let px = [0, 0, 0, 0, 0, 0, 0, 128]; + let (_, _, g) = decode_pam_gray(&pam(2, 1, &px)).expect("a P7 image"); + assert_eq!(g.first().copied(), Some(255), "fully transparent is white"); + assert_eq!(g.get(1).copied(), Some(127), "half-transparent black over white is mid grey"); + } + + #[test] + fn hostile_pam_headers_are_rejected() { + let px = [0u8; 8]; + assert!(decode_pam_gray(&pam(2, 1, &px)).is_some()); + let cases: Vec> = vec![ + b"".to_vec(), + b"P7\n".to_vec(), + b"P7\nWIDTH 2\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\n".to_vec(), // no ENDHDR + pam(2, 1, &[0u8; 4]), // truncated data + b"P6\nWIDTH 2\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(), + b"P7\nWIDTH 2\nHEIGHT 1\nDEPTH 3\nMAXVAL 255\nTUPLTYPE RGB\nENDHDR\n".to_vec(), // no alpha + b"P7\nWIDTH 2\nHEIGHT 1\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(), + b"P7\nWIDTH 99999999999999999999\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(), + ]; + for case in &cases { + assert!(decode_pam_gray(case).is_none(), "{:?}", String::from_utf8_lossy(case.get(..40).unwrap_or(case))); + } + // A declared area far larger than the data must fail on length, not allocate. + let huge = b"P7\nWIDTH 4000000000\nHEIGHT 4000000000\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n".to_vec(); + assert!(decode_pam_gray(&huge).is_none()); + } + + #[test] + fn identical_images_do_not_differ() { + let buf = gray(16, 16, 128); + let a = Gray::new(16, 16, &buf).expect("sized"); + let d = compare(a, a, 0).expect("same size"); + assert_eq!(d.differing, 0); + assert_eq!(d.max_delta, 0); + assert_eq!(d.fraction(), 0.0); + assert_eq!(d.total, 256); + } + + /// The whole point: a one-pixel shift of a stem is forgiven, because every moved pixel finds + /// its match next door. This is what makes the number mean something across renderers. + #[test] + fn a_one_pixel_shift_is_forgiven() { + let (w, h) = (9usize, 9usize); + let mut left = gray(w, h, 255); + let mut right = gray(w, h, 255); + for y in 0..h { + left[y * w + 4] = 0; // a vertical stem + right[y * w + 5] = 0; // the same stem, one pixel over + } + let a = Gray::new(w, h, &left).expect("sized"); + let b = Gray::new(w, h, &right).expect("sized"); + let d = compare(a, b, 8).expect("same size"); + assert_eq!(d.differing, 0, "a one-pixel shift must not count as a difference"); + // ...but it is still visible as a large single-pixel delta, which is the honest signal. + assert_eq!(d.max_delta, 255); + } + + /// And the point of the point: content that is simply absent is *not* forgiven, however + /// tolerant the neighbourhood test is. + #[test] + fn missing_content_is_not_forgiven() { + let (w, h) = (16usize, 16usize); + let blank = gray(w, h, 255); + let mut drawn = blank.clone(); + // A filled 6x6 block: its interior is further than one pixel from any white pixel. + for y in 5..11 { + for x in 5..11 { + drawn[y * w + x] = 0; + } + } + let a = Gray::new(w, h, &blank).expect("sized"); + let b = Gray::new(w, h, &drawn).expect("sized"); + let d = compare(a, b, 8).expect("same size"); + // Scanning the blank against the drawn image forgives the block's border, whose + // neighbourhood still reaches white, and counts only its 4x4 interior. Scanning the other + // way counts all 36 black pixels, since a blank image offers no dark match anywhere. The + // worse of the two is reported, so the whole block is -- which is the honest answer. + assert_eq!(d.differing, 36, "every pixel of a missing block must be counted"); + assert!(d.fraction() > 0.0); + } + + /// A three-pixel shift is beyond the 3x3 window and is reported. + #[test] + fn a_shift_wider_than_the_window_is_reported() { + let (w, h) = (16usize, 16usize); + let mut left = gray(w, h, 255); + let mut right = gray(w, h, 255); + for y in 0..h { + for x in 4..8 { + left[y * w + x] = 0; + } + for x in 11..15 { + right[y * w + x] = 0; + } + } + let a = Gray::new(w, h, &left).expect("sized"); + let b = Gray::new(w, h, &right).expect("sized"); + assert!(compare(a, b, 8).expect("same size").differing > 0); + } + + #[test] + fn tolerance_absorbs_small_differences_only() { + let flat = gray(8, 8, 100); + let near = gray(8, 8, 105); + let far = gray(8, 8, 200); + let a = Gray::new(8, 8, &flat).expect("sized"); + assert_eq!(compare(a, Gray::new(8, 8, &near).expect("sized"), 8).expect("ok").differing, 0); + assert_eq!(compare(a, Gray::new(8, 8, &far).expect("sized"), 8).expect("ok").differing, 64); + // Tolerance is inclusive at the boundary. + assert_eq!(compare(a, Gray::new(8, 8, &near).expect("sized"), 5).expect("ok").differing, 0); + assert_eq!(compare(a, Gray::new(8, 8, &near).expect("sized"), 4).expect("ok").differing, 64); + } + + /// Comparison is symmetric: neither argument order flatters one renderer. + #[test] + fn comparison_is_symmetric() { + let (w, h) = (12usize, 12usize); + let mut thin = gray(w, h, 255); + let mut thick = gray(w, h, 255); + for y in 0..h { + thin[y * w + 6] = 0; + for x in 4..9 { + thick[y * w + x] = 0; + } + } + let a = Gray::new(w, h, &thin).expect("sized"); + let b = Gray::new(w, h, &thick).expect("sized"); + let forward = compare(a, b, 8).expect("ok"); + let backward = compare(b, a, 8).expect("ok"); + assert_eq!(forward, backward, "argument order must not change the verdict"); + assert!(forward.differing > 0, "a 1px stem against a 5px bar is a real difference"); + } + + #[test] + fn different_sizes_are_an_error_not_a_score() { + let small = gray(4, 4, 0); + let large = gray(8, 8, 0); + let err = compare(Gray::new(4, 4, &small).expect("sized"), Gray::new(8, 8, &large).expect("sized"), 0).expect_err("mismatch"); + assert_eq!(err, SizeMismatch { a: (4, 4), b: (8, 8) }); + assert!(err.to_string().contains("4x4"), "{err}"); + } + + #[test] + fn a_buffer_that_is_not_the_stated_size_is_rejected() { + let buf = gray(4, 4, 0); + assert!(Gray::new(4, 4, &buf).is_some()); + assert!(Gray::new(5, 4, &buf).is_none()); + assert!(Gray::new(4, 5, &buf).is_none()); + assert!(Gray::new(usize::MAX, usize::MAX, &buf).is_none(), "the area must not overflow into a match"); + // A zero-sized image is representable and compares as no difference. + let empty: [u8; 0] = []; + let z = Gray::new(0, 0, &empty).expect("zero is a valid size"); + assert_eq!(compare(z, z, 0).expect("ok").fraction(), 0.0); + } + + #[test] + fn pgm_decodes_the_shape_pdftoppm_writes() { + let mut bytes = b"P5\n4 2\n255\n".to_vec(); + bytes.extend_from_slice(&[0, 1, 2, 3, 4, 5, 6, 7]); + let (w, h, px) = decode_pgm(&bytes).expect("a P5 image"); + assert_eq!((w, h), (4, 2)); + assert_eq!(px, vec![0, 1, 2, 3, 4, 5, 6, 7]); + } + + #[test] + fn pgm_handles_comments_and_odd_whitespace() { + let mut bytes = b"P5\n# written by pdftoppm\n 4\t2\n255 ".to_vec(); + bytes.extend_from_slice(&[9; 8]); + let (w, h, px) = decode_pgm(&bytes).expect("a commented P5 image"); + assert_eq!((w, h, px.len()), (4, 2, 8)); + } + + /// Malformed or hostile headers return `None` rather than panicking or inventing a size. + #[test] + fn hostile_pgm_headers_are_rejected() { + let cases: &[&[u8]] = &[ + b"", + b"P5", + b"P6\n4 2\n255\n", // colour, not grayscale + b"P5\n4 2\n65535\n", // 16-bit: would be read as twice the pixels + b"P5\n4 2\n255\n\x00", // truncated data + b"P5\n-4 2\n255\n", // negative + b"P5\n99999999999999999999 2\n255\n", + b"P5\n4\n", // header cut short + b"P5\n#unterminated comment", + ]; + for case in cases { + assert!(decode_pgm(case).is_none(), "{:?}", String::from_utf8_lossy(case)); + } + // A huge declared area must not allocate: it fails on the data length check. + let huge = b"P5\n4000000000 4000000000\n255\n".to_vec(); + assert!(decode_pgm(&huge).is_none()); + } +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index a612587d26..9f493e296f 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2025,10 +2025,10 @@ fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once"); } -// Stays ignored until CI installs Poppler: the pixel comparison has never run on a runner, so -// un-ignoring it here would turn an unverified test loose on everyone. See crates/oracle. +// Both sides of the comparison go through the same pdftoppm, so the assertion is about our two +// documents rather than about Poppler's output, and no Poppler version can change the verdict. +// It skips where pdftoppm is absent and fails where PDFCRAFT_ORACLES_REQUIRED is set. #[test] -#[ignore = "requires the external Poppler pdftoppm oracle"] fn extract_type3_shared_xobjects_matches_poppler_pixels() { let pdftoppm = pdfcraft_oracle::or_skip!(pdfcraft_oracle::pdftoppm()); let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id())); diff --git a/docs/gaps.md b/docs/gaps.md index 4c235c1745..94bd360e7a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -212,10 +212,25 @@ update the numbers. signature validation against Acrobat on synthetic fixtures; an interop suite that opens every kind of output we write (encrypted ones above all, after #774) in pdf.js, MuPDF and Poppler as oracle processes. -- **Evidence:** none exists; only text extraction has an oracle (word-F1 0.98 against `pdftotext`). - Acrobat captures must stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). -- **Impact:** until it exists, every quality number here is a judgement. -- **Estimate:** counted in gap 2 (≈ 20–30 h of it). **Doc:** [target-app-parity.md](target-app-parity.md). +- **Progress (2026-10-14):** rendering now has an oracle too. `cargo xtask render-oracle` compares + page 1 of every corpus file against Poppler's `pdftoppm` and reports a disagreement rate: the + share of pixels with no near match in the other renderer's 3×3 neighbourhood, which forgives + antialiasing and subpixel placement but not missing or wrong content (`crates/oracle`, module + `pixels`). **First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean + 2.16%, p90 5.35%, 25 within 0.1%.** It reports rather than gates — the corpus is not fetched on + CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. + The worst results cluster on JBIG2 `bitmap-*` files (`hayro-jbig2` carries 3 local patches) and + on ZapfDingbats, which points the renderer work at something specific for the first time. +- **Evidence:** text extraction (word-F1 0.98 against `pdftotext`) and now rendering (above); qpdf + checks written output on every CI run since `PDFCRAFT_ORACLES_REQUIRED`. Acrobat captures must + stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). +- **Still missing:** Acrobat itself as the reference rather than other open renderers; form + calculation and signature validation; the interop suite that opens every kind of output we write + (encrypted ones above all, after #774) in pdf.js and MuPDF; and 15 of those 250 files could not + be compared at all because the two renderers round the page box to different pixel sizes. +- **Impact:** until it is complete, quality numbers outside rendering and text are judgements. +- **Estimate:** counted in gap 2 (≈ 20–30 h of it; roughly a third done). + **Doc:** [target-app-parity.md](target-app-parity.md). ### 13. Performance budgets and lazy loading of very large files diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index cf3d164ce8..fdc63ba808 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -239,15 +239,125 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { Ok(()) } +/// Compare our rendering against Poppler's, page 1 of every PDF in a directory. +/// +/// This is the measured half of the fidelity gap (`docs/gaps.md` gap 12): until it exists, every +/// claim about rendering quality is a judgement. It reports rather than gates, because the corpus +/// is not fetched on CI and because a first reading of a borrowed renderer is a baseline to +/// improve on, not a bar to clear. +/// +/// The score is the share of pixels with no near match in the other renderer's 3x3 neighbourhood +/// (`pdfcraft_oracle::pixels`), which forgives antialiasing and subpixel placement but not +/// missing or wrong content. **Lower is better; it is a disagreement rate, not a quality score.** +pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { + let limit: usize = flag_num(args, "--limit").unwrap_or(usize::MAX); + let dpi: u32 = flag_num(args, "--dpi").unwrap_or(72); + let tolerance: u8 = flag_num(args, "--tolerance").unwrap_or(8); + let dir = + args.iter().position(|a| a == "--dir").and_then(|i| args.get(i + 1)).map_or_else(|| root().join("corpus/pdfjs/test/pdfs"), PathBuf::from); + + let pdftoppm = match pdfcraft_oracle::pdftoppm() { + Ok(tool) => tool, + Err(absent) => bail!("{absent}"), + }; + run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; + let cli = release_cli(); + + let mut files: Vec = std::fs::read_dir(&dir) + .with_context(|| format!("{}: no such directory (cargo xtask corpus, or pass --dir)", dir.display()))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))) + .collect(); + files.sort(); + + let out_pam = std::env::temp_dir().join(format!("pdfcraft-render-oracle-{}.pam", std::process::id())); + let (mut scores, mut sizes_differed, mut ours_failed, mut theirs_failed) = (Vec::new(), 0usize, 0usize, 0usize); + let mut worst: Vec<(f64, String)> = Vec::new(); + for file in files.iter().take(limit) { + let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let ours = Command::new(&cli) + .args(["render", "--page", "1", "--dpi"]) + .arg(dpi.to_string()) + .arg("--out") + .arg(&out_pam) + .arg(file) + .status() + .ok() + .filter(std::process::ExitStatus::success) + .and_then(|_| std::fs::read(&out_pam).ok()) + .and_then(|b| pdfcraft_oracle::pixels::decode_pam_gray(&b)); + let theirs = + run_capture_bytes(pdftoppm.command().args(["-singlefile", "-gray", "-f", "1", "-l", "1", "-r"]).arg(dpi.to_string()).arg(file), 30) + .and_then(|b| pdfcraft_oracle::pixels::decode_pgm(&b)); + // An oracle that cannot read a file says nothing about our fidelity, and a file we cannot + // read is `xtask check`'s business, not this one's. Count each side and move on. + if ours.is_none() { + ours_failed = ours_failed.saturating_add(1); + } + if theirs.is_none() { + theirs_failed = theirs_failed.saturating_add(1); + } + let (Some((aw, ah, a)), Some((bw, bh, b))) = (ours, theirs) else { continue }; + let (Some(a), Some(b)) = (pixels_of(aw, ah, &a), pixels_of(bw, bh, &b)) else { continue }; + match pdfcraft_oracle::pixels::compare(a, b, tolerance) { + Ok(diff) => { + scores.push(diff.percent()); + worst.push((diff.percent(), name)); + } + // Different rounding of the page box at this scale: not a fidelity signal. + Err(_) => sizes_differed = sizes_differed.saturating_add(1), + } + } + let _ = std::fs::remove_file(&out_pam); + + if scores.is_empty() { + bail!( + "no comparable files in {} ({sizes_differed} size mismatches, {ours_failed} we could not render, {theirs_failed} Poppler could not)", + dir.display() + ); + } + scores.sort_by(f64::total_cmp); + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + let median = scores.get(scores.len() / 2).copied().unwrap_or(0.0); + let mean = scores.iter().sum::() / scores.len() as f64; + let p90 = scores.get(scores.len().saturating_mul(9) / 10).copied().unwrap_or(0.0); + let clean = scores.iter().filter(|s| **s < 0.1).count(); + println!("render oracle vs Poppler at {dpi} dpi, tolerance {tolerance} (disagreement rate, lower is better)"); + println!(" {} compared: median {median:.2}%, mean {mean:.2}%, p90 {p90:.2}%", scores.len()); + println!(" {clean} within 0.1%; {sizes_differed} size mismatches, {ours_failed} we could not render, {theirs_failed} Poppler could not"); + println!("worst:"); + for (score, name) in worst.iter().take(12) { + println!(" {score:6.2}% {name}"); + } + Ok(()) +} + +/// The value after `name` on the command line, parsed, or `None` when it is absent or malformed. +fn flag_num(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i.checked_add(1)?)).and_then(|v| v.parse().ok()) +} + +fn pixels_of(w: usize, h: usize, buf: &[u8]) -> Option> { + pdfcraft_oracle::pixels::Gray::new(w, h, buf) +} + fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { + // `read_to_string` left the buffer empty when the output was not UTF-8; `from_utf8` keeps + // exactly that behaviour now that the capture itself is byte-oriented. + Some(String::from_utf8(run_capture_bytes(cmd, timeout_s)?).unwrap_or_default()) +} + +/// [`run_capture`] for output that is not text — an oracle's image on stdout, for instance. +fn run_capture_bytes(cmd: &mut Command, timeout_s: u64) -> Option> { let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn().ok()?; let start = std::time::Instant::now(); - let mut out = String::new(); + let mut out = Vec::new(); let mut stdout = child.stdout.take()?; let reader = std::thread::spawn(move || { use std::io::Read; - let mut s = String::new(); - let _ = stdout.read_to_string(&mut s); + let mut s = Vec::new(); + let _ = stdout.read_to_end(&mut s); s }); loop { @@ -262,7 +372,7 @@ fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { Err(_) => return None, } } - out.push_str(&reader.join().ok()?); + out.extend_from_slice(&reader.join().ok()?); Some(out) } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 70741d22b7..2e91afe2ac 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -33,6 +33,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ macos_integration::run, ), ("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle), + ( + "render-oracle", + "Compare page rendering with Poppler over corpus/ (pixel disagreement rate; --dir, --dpi, --limit, --tolerance)", + gates::render_oracle, + ), ("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run), ("models", "Fetch the OCR models (ATTRIBUTION.toml kind = \"model\") into assets/models/, verified by SHA-256", assets::models), ("demo-pdf", "Build dist/demo/pdfcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run), From 5e08aa8719394ef80dfe228d3f0c24ca82defbee Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 14:33:16 -0700 Subject: [PATCH 005/207] fix(oracle): say what the render-oracle score does and does not mean Investigating the worst file of the first 250 turned the headline finding around. bug1552113.pdf scores 84%, and the cause is Poppler: it paints the link's `/Border [0 0 112]` as a 112-unit band across a 250x50 page, hiding the two lines of text. The fixture is named "this text should be visible", and in our rendering it is. The oracle is not ground truth, and the top of its worst list needs a person to look at both images before it is read as a bug of ours. That led to a plausible change that the measurement rejected. Poppler synthesises appearances for annotations that have none, where we draw only what the document carries -- `/AP`-less link borders are already recorded as not covered under view.render-annotation-appearances -- so hiding annotations looked like the way to measure page content alone. It is worse: it then scores Poppler for every appearance we do draw. Over the same 250 files, shown gives mean 2.16%, p90 5.35% and 25 files within 0.1%; hidden gives 2.18%, 5.55% and 22, with annotation-tx3.pdf jumping to 45.64% and annotation-tx2.pdf to 18.79%. The two biases nearly cancel. So the default is unchanged and `--hide-annotations` is an opt-in for explaining a single file, with the numbers written down so the experiment is not repeated. Verified that the default reproduces the merged baseline exactly and that the flag moves it. Co-Authored-By: Claude Opus 5 --- crates/oracle/README.md | 7 +++++++ docs/gaps.md | 12 ++++++++++++ xtask/src/gates.rs | 23 ++++++++++++++++++++--- xtask/src/main.rs | 2 +- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/oracle/README.md b/crates/oracle/README.md index c94e080638..2f0d77aeaa 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -80,6 +80,13 @@ transparent margin is not scored as black. `cargo xtask render-oracle` drives it over the corpus. It reports; it does not gate. +A high score is a *disagreement*, not a verdict on us. The worst file of the first 250, +`bug1552113.pdf` at 84%, is Poppler painting a link's `/Border [0 0 112]` as a band over the text +the fixture exists to show; ours is the readable rendering. Look at both images before believing +the number. `--hide-annotations` separates annotation differences from page content, though the +default shows them: hiding is slightly worse overall, because it then scores Poppler for every +appearance we do draw. + ## Still to do - Acrobat itself as the reference, rather than other open renderers. diff --git a/docs/gaps.md b/docs/gaps.md index 94bd360e7a..318270ee7e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -221,6 +221,18 @@ update the numbers. CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. The worst results cluster on JBIG2 `bitmap-*` files (`hayro-jbig2` carries 3 local patches) and on ZapfDingbats, which points the renderer work at something specific for the first time. +- **Read the score as a disagreement, not a verdict (2026-10-14).** The worst file of the 250, + `bug1552113.pdf` at 84%, turned out to be Poppler's defect: it paints the link's `/Border [0 0 + 112]` as a 112-unit band over the two lines of text, and the fixture is named "this text should + be visible". Ours is the readable rendering. Anything at the top of the list needs a person to + look at both images before it is treated as a bug of ours. +- **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an + appearance for an annotation that has none, where we draw only what the document carries (link + borders without `/AP` are recorded as not covered under `view.render-annotation-appearances`), so + showing them scores us for a known gap. Hiding them is worse, because it then scores Poppler for + every appearance we *do* draw: over the same 250 files, shown gives mean 2.16% / p90 5.35% / 25 + files within 0.1%, hidden gives 2.18% / 5.55% / 22, with `annotation-tx3.pdf` jumping to 45.64%. + The biases nearly cancel; `--hide-annotations` tells the two apart for one file at a time. - **Evidence:** text extraction (word-F1 0.98 against `pdftotext`) and now rendering (above); qpdf checks written output on every CI run since `PDFCRAFT_ORACLES_REQUIRED`. Acrobat captures must stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index fdc63ba808..02c965e13d 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -253,6 +253,19 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { let limit: usize = flag_num(args, "--limit").unwrap_or(usize::MAX); let dpi: u32 = flag_num(args, "--dpi").unwrap_or(72); let tolerance: u8 = flag_num(args, "--tolerance").unwrap_or(8); + // Annotations are shown by default, which is not the obvious choice and was settled by + // measurement rather than argument. Poppler synthesises an appearance for an annotation that + // has none, where we draw only what the document carries (`view.render-annotation-appearances` + // records link borders without `/AP` as not covered), so showing them scores us for a known + // gap: bug1552113.pdf is 84% purely because Poppler paints a 112-unit link border over the + // text that fixture exists to show -- and our rendering, not Poppler's, is the readable one. + // + // Hiding them is worse, though, because it scores Poppler for every appearance we *do* draw. + // Over the same 250 files: shown gives mean 2.16%, p90 5.35%, 25 files within 0.1%; hidden + // gives 2.18%, 5.55% and 22, with annotation-tx3.pdf jumping to 45.64%. The two biases very + // nearly cancel, so the default stays with the fuller picture and this flag exists to tell + // the two apart when a single file's score needs explaining. + let hide_annotations = args.iter().any(|a| a == "--hide-annotations"); let dir = args.iter().position(|a| a == "--dir").and_then(|i| args.get(i + 1)).map_or_else(|| root().join("corpus/pdfjs/test/pdfs"), PathBuf::from); @@ -287,9 +300,13 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { .filter(std::process::ExitStatus::success) .and_then(|_| std::fs::read(&out_pam).ok()) .and_then(|b| pdfcraft_oracle::pixels::decode_pam_gray(&b)); - let theirs = - run_capture_bytes(pdftoppm.command().args(["-singlefile", "-gray", "-f", "1", "-l", "1", "-r"]).arg(dpi.to_string()).arg(file), 30) - .and_then(|b| pdfcraft_oracle::pixels::decode_pgm(&b)); + let mut theirs_cmd = pdftoppm.command(); + theirs_cmd.args(["-singlefile", "-gray", "-f", "1", "-l", "1", "-r"]).arg(dpi.to_string()); + if hide_annotations { + theirs_cmd.arg("-hide-annotations"); + } + theirs_cmd.arg(file); + let theirs = run_capture_bytes(&mut theirs_cmd, 30).and_then(|b| pdfcraft_oracle::pixels::decode_pgm(&b)); // An oracle that cannot read a file says nothing about our fidelity, and a file we cannot // read is `xtask check`'s business, not this one's. Count each side and move on. if ours.is_none() { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2e91afe2ac..f06c099441 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,7 +35,7 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle), ( "render-oracle", - "Compare page rendering with Poppler over corpus/ (pixel disagreement rate; --dir, --dpi, --limit, --tolerance)", + "Compare page rendering with Poppler over corpus/ (pixel disagreement rate; --dir, --dpi, --limit, --tolerance, --hide-annotations)", gates::render_oracle, ), ("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run), From e0c63546bb8676fb028935fd97ee7a25722a0f2b Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 15:08:29 -0700 Subject: [PATCH 006/207] test(render): pin that every JBIG2 conformance fixture decodes to the same picture The render oracle put four JBIG2 fixtures at the top of its disagreement list against Poppler, and the obvious reading was that hayro-jbig2, which carries local patches, had a bug. Investigating it found the reverse. The 96 `bitmap-*.pdf` corpus files are a conformance suite: one small drawing encoded through every corner of JBIG2 -- each generic-region template, custom AT pixels, TPGDON and TPGRON, refinement, halftone with and without skip, symbol dictionaries with Huffman tables, MMR, the composition operators, random access, unknown-height segments. Those features change how the bytes are compressed, not what the picture is, so a correct decoder renders all 96 identically. That is a real test with no reference image and no external oracle: the suite checks itself. We render 1 distinct image across all 96. Poppler renders 8: it draws a blank page for the seven halftone-skip and symbol fixtures, and its output changes when TPGRON is switched on, which a correct decoder's cannot. So roughly a fifth of Poppler's JBIG2 output is wrong, the top of the oracle's worst list is its bug rather than ours, and Poppler is not a usable oracle for JBIG2. Watched failing by admitting one unrelated file to the set: the test fails and names it as the odd one out rather than merely reporting a count. The expectation holds of the pinned corpus. Whoever re-pins it to a newer pdf.js should re-check this rather than assume it still holds, which the test's own documentation says. Co-Authored-By: Claude Opus 5 --- crates/oracle/README.md | 5 ++ crates/render/tests/jbig2_invariants.rs | 100 ++++++++++++++++++++++++ docs/gaps.md | 9 +++ 3 files changed, 114 insertions(+) create mode 100644 crates/render/tests/jbig2_invariants.rs diff --git a/crates/oracle/README.md b/crates/oracle/README.md index 2f0d77aeaa..7d2996b77b 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -87,6 +87,11 @@ the number. `--hide-annotations` separates annotation differences from page cont default shows them: hiding is slightly worse overall, because it then scores Poppler for every appearance we do draw. +**Poppler is not a usable oracle for JBIG2.** The 96 `bitmap-*.pdf` corpus fixtures encode one +picture through every corner of the format, so a correct decoder renders them all identically. We +render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. +`crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. + ## Still to do - Acrobat itself as the reference, rather than other open renderers. diff --git a/crates/render/tests/jbig2_invariants.rs b/crates/render/tests/jbig2_invariants.rs new file mode 100644 index 0000000000..4367825733 --- /dev/null +++ b/crates/render/tests/jbig2_invariants.rs @@ -0,0 +1,100 @@ +//! Every JBIG2 conformance fixture in the corpus encodes the same picture. +//! +//! The 96 `bitmap-*.pdf` files are a conformance suite: one small drawing, encoded through every +//! corner of JBIG2 — generic regions with each template and with custom AT pixels, typical +//! prediction (TPGDON, TPGRON), refinement, halftone regions with and without skip, symbol +//! dictionaries with Huffman tables, MMR, composition operators, random access, unknown-height +//! segments. The features change how the bytes are compressed. They do not change the picture. +//! +//! So a correct decoder renders all 96 identically, and any file that comes out different is a +//! decoder bug. That is a real test with no reference image and no external oracle: the suite +//! checks itself. +//! +//! It is worth having because the property settled a question the render oracle raised and could +//! not answer. `cargo xtask render-oracle` put four JBIG2 fixtures at the top of its disagreement +//! list against Poppler, and the natural reading was that `hayro-jbig2`, which carries local +//! patches, had a bug. The opposite is true. Measured over this suite: **we render 1 distinct +//! image across all 96; Poppler renders 8**, drawing nothing at all for the seven halftone-skip +//! and symbol fixtures and mis-decoding TPGRON refinement. An oracle is not ground truth. This +//! invariant is. +//! +//! Runs over `corpus/pdfjs/test/pdfs` when present (`cargo xtask corpus` fetches and verifies it); +//! a no-op when absent, so CI without the corpus stays green. The expectation that every fixture +//! holds the same picture is a property of the **pinned** corpus; whoever re-pins it to a newer +//! pdf.js should re-check this test rather than assume it still holds. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +/// `corpus/pdfjs/test/pdfs`, or `None` when nothing has been fetched. +/// +/// Panics when the checkout exists without the stamp `cargo xtask corpus` writes on a successful +/// verification: an unverified corpus must fail loudly rather than skip, as in `pdfcraft-cos`'s +/// corpus tests. +fn corpus() -> Option { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs"); + if !dir.is_dir() { + return None; + } + assert!( + dir.join(".pdfcraft-corpus.json").is_file(), + "{} exists but has not been verified against the pin; run `cargo xtask corpus`", + dir.display() + ); + Some(dir.join("test/pdfs")) +} + +/// Page 1 as premultiplied RGBA at 1:1, or `None` when it could not be rendered. +fn render(path: &std::path::Path) -> Option<(u32, u32, Vec)> { + let bytes = std::fs::read(path).ok()?; + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig { reject_oversize: true, ..Default::default() }); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + page.error.is_none().then_some((page.width, page.height, page.rgba.to_vec())) +} + +#[test] +#[ignore = "needs corpus/; run with --ignored"] +fn every_jbig2_conformance_fixture_decodes_to_the_same_picture() { + let Some(dir) = corpus() else { + eprintln!("corpus not fetched; skipping (cargo xtask corpus)"); + return; + }; + let mut fixtures: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "pdf") && p.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.starts_with("bitmap"))) + .collect(); + fixtures.sort(); + + // Group by what each one drew. One group means every feature decoded to the same picture. + let mut groups: BTreeMap<(u32, u32, Vec), Vec> = BTreeMap::new(); + let mut unrenderable = Vec::new(); + for path in &fixtures { + let name = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + match render(path) { + Some(image) => groups.entry(image).or_default().push(name), + None => unrenderable.push(name), + } + } + + assert!(fixtures.len() >= 90, "expected the JBIG2 conformance fixtures in the pinned corpus, found {}", fixtures.len()); + assert!(unrenderable.is_empty(), "every fixture must render: {unrenderable:?}"); + + if groups.len() > 1 { + // Name the odd ones out: the largest group is what the picture should be, so anything + // else is the bug, and saying which files they are is the whole value of the failure. + let mut sized: Vec<(usize, &Vec)> = groups.values().map(|names| (names.len(), names)).collect(); + sized.sort_by_key(|(n, _)| std::cmp::Reverse(*n)); + let report: Vec = sized.iter().skip(1).map(|(n, names)| format!("{n} file(s): {names:?}")).collect(); + panic!( + "JBIG2 coding features must not change the picture, but {} of {} fixtures decoded differently:\n {}", + fixtures.len().saturating_sub(sized.first().map_or(0, |(n, _)| *n)), + fixtures.len(), + report.join("\n ") + ); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 318270ee7e..34efa4eff1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -226,6 +226,15 @@ update the numbers. 112]` as a 112-unit band over the two lines of text, and the fixture is named "this text should be visible". Ours is the readable rendering. Anything at the top of the list needs a person to look at both images before it is treated as a bug of ours. +- **The JBIG2 cluster was Poppler too, and the suite checks itself (2026-10-14).** The next four + worst files were JBIG2, which looked like a defect in `hayro-jbig2` and its local patches. It is + the reverse. The 96 `bitmap-*.pdf` fixtures are a conformance suite: one picture encoded through + every corner of JBIG2, so a correct decoder renders all 96 identically — a real test needing no + reference image and no oracle. **We render 1 distinct image across all 96; Poppler renders 8**, + drawing a blank page for the seven halftone-skip and symbol fixtures and mis-decoding TPGRON + refinement (its output changes when TPGRON is switched on, which a correct decoder's cannot). + Pinned as `crates/render/tests/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**, + and roughly a fifth of its fixtures should be read as its bugs rather than ours. - **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an appearance for an annotation that has none, where we draw only what the document carries (link borders without `/AP` are recorded as not covered under `view.render-annotation-appearances`), so From f802257be642cc767880d4fe4a1491247c92d765 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 15:32:16 -0700 Subject: [PATCH 007/207] docs(fonts): the README still called the standard-14 widths an estimate F1 replaced the character-class approximation with the published standard-14 metrics, and `helvetica_width` has delegated to `Std14::Helvetica` ever since. The README kept describing it as PdfCraft's own estimate and, more importantly, said "No metrics file or font program from any vendor is bundled" -- which stopped being true when the generated tables landed. They are Adobe-authored non-visual technical data, the item AGENTS.md 1.1 permits, carried in std14_metrics.rs with adobe_data = true in ATTRIBUTION.toml. A contributor reading the README would have concluded there was no vendor data here at all, which is the one kind of error that matters in a provenance claim. Says what is bundled now, and what still is not: widths and encodings, never a font program or a typeface. Co-Authored-By: Claude Opus 5 --- crates/fonts/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 76fd89dd88..e7e3ef117f 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -4,9 +4,11 @@ Layer L2. Font helpers for the appearance streams PdfCraft generates (comments, Today it holds what generated appearances need without a font program: -- `helvetica_width`: an approximation of Helvetica's proportions by character class. No metrics - file or font program from any vendor is bundled; widths are PdfCraft's own estimates, good - enough for line breaking and alignment, not for typesetting. +- `helvetica_width`: the **exact** standard-14 advance widths, via `Std14::Helvetica`. It was an + estimate by character class until the generated tables landed; those tables are Adobe-authored + non-visual technical data, the item `AGENTS.md` §1.1 permits, carried in `std14_metrics.rs` with + `adobe_data = true` in `ATTRIBUTION.toml`. **No font program or typeface from any vendor is + bundled** — widths and encodings only. Reach for `Std14` directly for any other face. - `wrap`: greedy line breaking with that measure (paragraphs on newlines, long words split). - `win_ansi`: Unicode → WinAnsiEncoding bytes (`?` for characters it can't represent), and `literal` to write bytes as a PDF literal string. Nothing writes that `?` silently. Callers From ac89a113915ea890999cf1a141cdfcda1ad06d98 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 14 Oct 2026 15:51:09 -0700 Subject: [PATCH 008/207] fix(xtask): the text oracle scored us zero for honouring a no-copy document A PDF can forbid copying its text. `pdfcraft-cli text` honours that and refuses, naming the owner password as the way through. pdftotext ignores the flag and extracts anyway, so the oracle was comparing our refusal against its text and scoring 0.000 -- measuring obedience, not quality. bug1815476.pdf is the case: a one-page Spanish form we render perfectly, whose text we decline to extract and pdftotext hands over regardless. It sat at the bottom of the worst list looking like a font bug, which is what sent me looking at it. Those files are now skipped and counted, not scored, and the count is printed rather than passed over in silence, on the same rule the oracle crate exists for: a comparison that did not happen must not look like one that succeeded. Over the first 250 corpus files that moves the mean from 0.811 to 0.854 and drops one file from the sample, with the median unchanged at 0.991. Permissions come from `Document::permissions`, so this is not a match on our own error text. A file that cannot be opened at all counts as permitted, because that is a different problem and dropping it quietly would hide it. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + xtask/Cargo.toml | 1 + xtask/src/gates.rs | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ae648efe69..93a8545ec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7959,6 +7959,7 @@ version = "0.1.0" dependencies = [ "anyhow", "lopdf", + "pdfcraft-cos", "pdfcraft-oracle", "png 0.18.1", "serde", diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 1171dfc568..7c6ca7aedb 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] pdfcraft-oracle = { workspace = true } +pdfcraft-cos = { workspace = true } anyhow = "1" lopdf = "0.45" png = "0.18" diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 02c965e13d..6591233e91 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -212,7 +212,16 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let cli = release_cli(); let mut scores = Vec::new(); let mut worst: Vec<(f64, String)> = Vec::new(); + let mut not_permitted = 0usize; for f in files.iter().take(limit) { + // A document may forbid copying its text, and we honour that: `pdfcraft-cli text` refuses + // and says how to override it with the owner password. pdftotext ignores the flag and + // extracts anyway, so scoring us against it there measures obedience, not quality -- + // bug1815476.pdf scored 0.000 for behaving correctly on a page we render perfectly. + if !copying_allowed(f) { + not_permitted = not_permitted.saturating_add(1); + continue; + } let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20); let theirs = run_capture(pdftotext.command().arg("-q").arg(f).arg("-"), 20); let (Some(ours), Some(theirs)) = (ours, theirs) else { continue }; @@ -232,6 +241,9 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let median = scores[scores.len() / 2]; let mean = scores.iter().sum::() / scores.len() as f64; println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]); + if not_permitted > 0 { + println!(" {not_permitted} skipped: the document forbids copying its text, which we honour and pdftotext does not"); + } println!("lowest:"); for (s, n) in worst.iter().take(12) { println!(" {s:.3} {n}"); @@ -350,6 +362,17 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { Ok(()) } +/// Whether the document lets its text be copied. +/// +/// `true` when it is unencrypted, when the flag is set, and when the file cannot be opened at all +/// — a file we cannot read is a different problem, and silently dropping it from the comparison +/// would hide it. +fn copying_allowed(path: &Path) -> bool { + let Ok(bytes) = std::fs::read(path) else { return true }; + let Ok(doc) = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)) else { return true }; + doc.permissions().is_none_or(|p| p.copy()) +} + /// The value after `name` on the command line, parsed, or `None` when it is absent or malformed. fn flag_num(args: &[String], name: &str) -> Option { args.iter().position(|a| a == name).and_then(|i| args.get(i.checked_add(1)?)).and_then(|v| v.parse().ok()) From d3a91c5c5f7b33dd39bc2ed8b5d3916960736d24 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Thu, 15 Oct 2026 03:44:02 +0000 Subject: [PATCH 009/207] M2: use fixed-size pixel chunks in oracle decoder (#2342) Keep RGBA decoding unchanged while satisfying the current Clippy lint. Include the separately verified Rustls 0.23.46 lock prerequisite. Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex --- Cargo.lock | 4 ++-- crates/oracle/src/pixels.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93a8545ec7..3a63eb57d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5411,9 +5411,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 7365e37eaf..2b11719819 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -186,7 +186,7 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> { return None; } let mut out = Vec::with_capacity(width.checked_mul(height)?); - for px in data.get(..wanted)?.chunks_exact(4) { + for px in data.get(..wanted)?.as_chunks::<4>().0 { let (&r, &g, &b, &a) = (px.first()?, px.get(1)?, px.get(2)?, px.get(3)?); // Composite premultiplied RGB over white, in integer arithmetic that cannot overflow: // each term is at most 255, and the sum is at most 255. From dbef872ed77b3a955d51b8eec61171d08cb6f423 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Thu, 15 Oct 2026 06:16:13 +0000 Subject: [PATCH 010/207] M2: preserve precision when writing page transforms (#946) Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex --- ROADMAP.md | 1 + crates/edit/src/added.rs | 13 +++++++++-- crates/edit/src/lib.rs | 19 ++++++++++----- crates/edit/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++++ crates/print/src/lib.rs | 39 ++++++++++++++++++------------- crates/print/src/tests.rs | 49 +++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- 7 files changed, 144 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b4ab4ea8a6..309d4dccd0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 9f13047b01..12008dee87 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -14,7 +14,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi}; use unicode_bidi::{Level, ParagraphBidiInfo}; -use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged}; +use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; const TAG: &str = "Added"; @@ -701,7 +701,16 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash /// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it. fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { let mut res = Dict::new(); - let mut out = format!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes(); + let mut out = format!( + "q {} {} {} {} {} {} cm\n", + matrix_number(view[0]), + matrix_number(view[1]), + matrix_number(view[2]), + matrix_number(view[3]), + matrix_number(view[4]), + matrix_number(view[5]) + ) + .into_bytes(); match c { Content::Text(t) => { let base = t.family.base_font(t.bold, t.italic); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d92f284052..336e948ae9 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -172,6 +172,13 @@ pub struct Context { pub date: (i64, u32, u32), } +// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision. +fn matrix_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + fn n(v: f64) -> String { let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); let s = s.trim_end_matches('0').trim_end_matches('.'); @@ -365,12 +372,12 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { format!( "q\n/Artifact <> BDC\n{} {} {} {} {} {} cm\n", kind.tag(), - n(matrix[0]), - n(matrix[1]), - n(matrix[2]), - n(matrix[3]), - n(matrix[4]), - n(matrix[5]) + matrix_number(matrix[0]), + matrix_number(matrix[1]), + matrix_number(matrix[2]), + matrix_number(matrix[3]), + matrix_number(matrix[4]), + matrix_number(matrix[5]) ) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index fce7d5e101..ecbedb9b4f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3443,3 +3443,49 @@ fn an_items_measured_face_is_the_face_it_is_written_as() { let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic); assert_eq!(courier(false), courier(true)); } + +#[test] +fn page_marks_and_added_content_preserve_fractional_user_unit_matrices() { + for rotation in [0, 90] { + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[2].obj; + doc.update_dict(page, |d| { + let bounds = Object::Array([0.0, 0.0, 0.1, 0.1].into_iter().map(Object::Real).collect()); + d.set(b"MediaBox".to_vec(), bounds.clone()); + d.set(b"CropBox".to_vec(), bounds); + d.set(b"UserUnit".to_vec(), Object::Int(4096)); + d.set(b"Rotate".to_vec(), Object::Int(rotation)); + }) + .unwrap(); + let mut hf = HeaderFooter::default(); + hf.text[0] = "Matrix header".into(); + add_header_footer(&mut doc, &[2], &hf, false, &cx()).unwrap(); + let text = AddedText { rect: [72.0, 100.0, 200.0, 144.0], text: "Matrix text".into(), size: 12.0, ..AddedText::default() }; + add_content(&mut doc, 2, &Content::Text(text)).unwrap(); + let saved = reopen(&doc); + let contents = streams(&saved, 2); + for marker in ["/PCMark /HeaderFooter", "(Matrix text) Tj"] { + let matching: Vec<_> = contents.iter().filter(|s| s.contains(marker)).collect(); + assert_eq!(matching.len(), 1, "{contents:?}"); + let stream = matching[0]; + let parsed = pdfcraft_content::parse(stream.as_bytes()); + assert_eq!(parsed.skipped, 0); + let cm = parsed.ops.iter().find(|op| op.is("cm")).unwrap(); + assert_eq!(cm.operands.len(), 6); + assert!(!stream[cm.span.clone()].contains('e') && !stream[cm.span.clone()].contains('E'), "PDF numbers cannot use exponents"); + let matrix = cm.nums::<6>().unwrap(); + let expected = if rotation == 0 { + [0.000244140625, 0.0, 0.0, 0.000244140625, 0.0, 0.0] + } else { + [0.0, 0.000244140625, -0.000244140625, 0.0, 0.1, 0.0] + }; + assert_eq!(matrix, expected, "rotation={rotation}, stream={stream}"); + // A fixed physical display point must remain at its intended raw page position. + let raw = [matrix[0] * 72.0 + matrix[2] * 144.0 + matrix[4], matrix[1] * 72.0 + matrix[3] * 144.0 + matrix[5]]; + let expected_raw = if rotation == 0 { [0.017578125, 0.03515625] } else { [0.06484375, 0.017578125] }; + for (actual, expected) in raw.into_iter().zip(expected_raw) { + assert!((actual - expected).abs() < 1e-12); + } + } + } +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b4ed599594..cbffdc4e2b 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -506,6 +506,13 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P Ok(sheets) } +// Model-derived transforms and clipping bounds need their full PDF decimal precision. +fn geometry_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + fn n(v: f64) -> String { let s = format!("{v:.4}"); let s = s.trim_end_matches('0').trim_end_matches('.'); @@ -669,22 +676,22 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let [x0, y0, x1, y1] = pl.clip; c.push_str(&format!( "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", - n(a), - n(b), - n(cc), - n(d), - n(e), - n(f), - n(x0), - n(y0), - n(x1 - x0), - n(y1 - y0), - n(ua), - n(ub), - n(uc), - n(ud), - n(ue), - n(uf) + geometry_number(a), + geometry_number(b), + geometry_number(cc), + geometry_number(d), + geometry_number(e), + geometry_number(f), + geometry_number(x0), + geometry_number(y0), + geometry_number(x1 - x0), + geometry_number(y1 - y0), + geometry_number(ua), + geometry_number(ub), + geometry_number(uc), + geometry_number(ud), + geometry_number(ue), + geometry_number(uf) )); } if !sheet.borders.is_empty() || !sheet.lines.is_empty() { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index ee21c2706c..e878624087 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -643,3 +643,52 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() assert_eq!(matching_paper(size), None); } } + +#[test] +fn imposed_pages_preserve_large_user_unit_matrices() { + let (unit, mode) = (75000.0, SizeMode::Fit); + let mut doc = fixture(1); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Real(unit))).unwrap(); + let out = impose(&doc, &Settings { content: Content::Document, ..settings(vec![0], Layout::Size(mode)) }).unwrap(); + let printed = Document::open(Arc::new(out)).unwrap(); + let page = &pdfcraft_model::pages(&printed)[0]; + let contents = printed.resolve(page.dict.get(b"Contents").unwrap()); + let Object::Stream(stream) = &*contents else { panic!("sheet content") }; + let bytes = stream.decoded().unwrap(); + let parsed = pdfcraft_content::parse(&bytes); + assert_eq!(parsed.skipped, 0); + let cms: Vec<_> = parsed.ops.iter().filter(|op| op.is("cm")).collect(); + assert_eq!(cms.len(), 2, "placement and source-to-display matrices"); + let normalized = f64::from(unit as f32); + let scale = if mode == SizeMode::Actual { 1.0 } else { 756.0 / (300.0 * normalized) }; + let expected = [ + [scale, 0.0, 0.0, scale, 306.0 - 100.0 * normalized * scale, 396.0 - 150.0 * normalized * scale], + [normalized, 0.0, 0.0, normalized, 0.0, 0.0], + ]; + for (cm, expected) in cms.iter().zip(expected) { + assert_eq!(cm.operands.len(), 6); + assert!(!bytes[cm.span.clone()].contains(&b'e') && !bytes[cm.span.clone()].contains(&b'E')); + for (actual, expected) in cm.nums::<6>().unwrap().into_iter().zip(expected) { + assert!((actual - expected).abs() <= 1e-12 * expected.abs(), "unit={unit}: {actual} != {expected}"); + } + } + let clip = parsed.ops.iter().find(|op| op.is("re")).unwrap(); + assert_eq!(clip.operands.len(), 4); + let rect = clip.nums::<4>().unwrap(); + assert_eq!(rect, [0.0, 0.0, 200.0 * normalized, 300.0 * normalized]); + assert!(rect[2] > 0.0 && rect[3] > 0.0, "the clip must retain the visible page"); + let clip_tokens = std::str::from_utf8(&bytes[clip.span.clone()]).unwrap(); + assert!(clip_tokens.split_whitespace().take(4).all(|token| !token.contains('e') && !token.contains('E'))); + let apply = |m: [f64; 6], p: [f64; 2]| [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]]; + let to_sheet = |p| apply(cms[0].nums::<6>().unwrap(), apply(cms[1].nums::<6>().unwrap(), p)); + let mut anchors = vec![([100.0, 150.0], [306.0, 396.0])]; + if mode == SizeMode::Fit { + anchors.extend([([0.0, 0.0], [54.0, 18.0]), ([200.0, 300.0], [558.0, 774.0])]); + } + for (raw, expected) in anchors { + for (actual, expected) in to_sheet(raw).into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-9, "unit={unit}: {actual} != {expected}"); + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index fabb5e603d..95e9142efa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | From e75082c9d6f0655d56e638369ed6ec2cbdb8b2a3 Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 15 Oct 2026 08:16:23 +0200 Subject: [PATCH 011/207] Printing: a layer that is off in the document stays off on the sheets (#1135) The print-ready PDF that `pdfcraft_print::impose` builds starts from a new catalog with only /Type and /Pages, so the document's /OCProperties was not carried over. The pages' marked content still refers to its optional-content groups, but nothing said a group was off, so a layer that is switched off in the document (and not drawn on screen) was printed anyway. Keep /OCProperties in the new catalog, so the sheets hide the same layers the document hides. Test: a one-page document whose red square is in a layer; with the layer off by default it is not drawn on screen and, now, not on the printed sheet; with the layer on it is drawn in both. Printing still follows the layers' default state only; applying the Print event of /AS (a layer shown only on screen or only in print) is not done here. --- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ crates/print/src/lib.rs | 6 ++++++ 2 files changed, 42 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37cafd440a..2d088d4e86 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1827,6 +1827,42 @@ fn printing_lays_out_sheets_that_render() { assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); } +/// A one-page document whose red square is in a layer; `off` switches the layer off by default. +fn red_layer_pdf(off: bool) -> Vec { + let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC"; + let off = if off { "/OFF [5 0 R] " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << {off}/Order [5 0 R] >> >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /OCG /Name (Red) >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() +} + +/// Does the first page of `pdf` show any red when drawn? +fn first_page_has_red(pdf: Vec) -> bool { + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf), pdfcraft_render::RenderConfig::default()); + let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 0.5, ..Default::default() }); + page.rgba.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 60 && p[2] < 60) +} + +#[test] +fn a_layer_that_is_off_does_not_print_and_one_that_is_on_does() { + for (off, prints) in [(true, false), (false, true)] { + let source = red_layer_pdf(off); + assert_eq!(first_page_has_red(source.clone()), prints, "on screen (off: {off})"); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(source), None).unwrap(); + let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + assert_eq!(first_page_has_red(sheet), prints, "printed (layer off by default: {off})"); + } +} + #[test] fn crop_and_duplicate_pages_show_in_the_viewer() { let (mut s, id) = session_with(2); diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index cbffdc4e2b..7e6413d3cf 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -723,6 +723,12 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let mut cat = Dict::new(); cat.set(b"Type".to_vec(), Object::name("Catalog")); cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + // Keep the layers' configuration, or a layer that is off in the document would print: the + // pages' marked content still refers to its groups, and without this nothing says they are off. + let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); + if let Some(layers) = layers { + cat.set(b"OCProperties".to_vec(), layers); + } let root = doc.add(Object::Dict(cat)); doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); doc.trailer_mut().remove(b"Info"); From 039706c4371d1fe0fb4d693a993212e885f4ed81 Mon Sep 17 00:00:00 2001 From: Jules Kreuer <25013642+not-a-feature@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:16:35 +0200 Subject: [PATCH 012/207] Clear image leftovers (#1151) * M8: Remove image redaction leftovers * M8: Move roadmap note --- ROADMAP.md | 1 + crates/redact/README.md | 3 ++- crates/redact/src/image.rs | 5 ++++ crates/redact/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 3 ++- 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 309d4dccd0..baedd7fbbe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -444,5 +444,6 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | | 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | +| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | - **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags. diff --git a/crates/redact/README.md b/crates/redact/README.md index 4a569eaa18..8ff86e9603 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -20,7 +20,8 @@ clear_marks(&mut doc, None)?; // remove marks without app - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed; + area are removed. Images with separate `/SMask` or `/Mask` data are removed whole; + cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 4d61fd6c64..043a77db95 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -41,6 +41,10 @@ pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] } fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option> { + // Separate masks can retain the original image data. + if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") { + return None; + } let supported = |n: &[u8]| { matches!( n, @@ -114,5 +118,6 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let mut dict = s.dict.clone(); dict.remove(b"Length"); dict.remove(b"DecodeParms"); + dict.remove(b"Alternates"); Some(Some(Stream::flate(dict, &data))) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 09723b7630..dd319846f8 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -920,3 +920,50 @@ fn redacting_every_inheriting_page_retires_the_parent_resources() { assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0); } } + +#[test] +fn image_alternatives_and_masks_cannot_keep_redacted_pixels() { + for extra in ["/Alternates [<< /Image 8 0 R >>]", "/SMask 8 0 R", "/Mask 8 0 R", "/Mask [0 0]"] { + let mut doc = one_page( + b"q 0 0 300 300 re W n 8 0 0 10 10 10 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![ + stream( + &format!("/Type /XObject /Subtype /Image /Width 8 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 {extra}"), + b"LEAK_TOPPUBLIC__", + ), + stream("/Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"LEAK_ALT"), + ], + ); + mark(&mut doc, 0, &[[10.0, 15.0, 18.0, 20.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_eq!((report.images_cleared, report.images_removed), if extra.starts_with("/Alternates") { (1, 0) } else { (0, 1) }); + } +} + +#[test] +fn masks_and_alternates_outside_redaction_stay() { + let mut doc = one_page( + b"q 8 0 0 10 10 10 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Image /Width 8 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 8 0 R /Alternates [<< /Image 8 0 R >>]", + b"PUBLIC_TOPBOTTOM", + ), + stream("/Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC__"), + ], + ); + mark(&mut doc, 0, &[[100.0, 100.0, 120.0, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_removed, report.images_cleared), (0, 0)); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let xobjects = saved.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let image = saved.resolve(xobjects.as_dict().unwrap().get(b"Im").unwrap()); + let Object::Stream(image) = &*image else { panic!() }; + assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates")); + assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 95e9142efa..9b9a39b62d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -108,7 +108,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | 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.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. 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).
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 | @@ -197,4 +197,5 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-10 | minor | M3 records the System appearance completion after #881; estimates unchanged | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | +| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | From fc601b89e117a12b1723054d151f5cfe72581dfc Mon Sep 17 00:00:00 2001 From: Earl Co Date: Thu, 15 Oct 2026 08:16:45 +0200 Subject: [PATCH 013/207] Tests: use a 300 DPI scan for OCR searchability (#1197) --- crates/engine/src/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d088d4e86..2bc630a619 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2639,7 +2639,8 @@ fn recognize_text_makes_a_scanned_page_searchable() { 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(); - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).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(); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); @@ -2656,7 +2657,9 @@ fn recognize_text_makes_a_scanned_page_searchable() { let q = words.iter().find(|w| w.text.to_lowercase().contains("quick")).unwrap(); let l = words.iter().find(|w| w.text.to_lowercase().contains("lazy")).unwrap(); let top = |w: &ocr::PlacedWord| w.origin[1] + w.up[1]; - assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < 3.0, "{q:?} {l:?}"); + // OCR boxes can differ by a few pixels at 300 DPI and still sit on the same line. + let line_tolerance = q.up[1].abs().max(l.up[1].abs()) / 4.0; + assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < line_tolerance, "{q:?} {l:?}"); // A second pass skips the page: it has text now. let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); From 8a04b6d5c2785e5262b83f4fc06c9520f15b1eec Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 08:16:56 +0200 Subject: [PATCH 014/207] M8: Bound and reuse repeated redaction overlays (#1426) Consolidate the reviewed change and its follow-ups on current upstream. Original change notes: M8: Bound and reuse repeated redaction overlays Prepare appearances within a shared 8 MiB budget before changing the document. Reuse one measured and encoded row, reject excessive geometry, and preserve document and undo state on refusal. Fixes #1326 M8: Preserve upstream font metrics in bounded redaction overlays M8: reuse ordered redaction mark groups (#1326) Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> --- crates/engine/src/tests.rs | 25 +++++++ crates/redact/README.md | 7 ++ crates/redact/src/lib.rs | 99 +++++-------------------- crates/redact/src/overlay.rs | 129 +++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 135 +++++++++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 80 deletions(-) create mode 100644 crates/redact/src/overlay.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2bc630a619..5e1a288b54 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1802,6 +1802,31 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +#[test] +fn oversized_redaction_overlay_preserves_the_document_and_undo() { + let (mut s, id) = session_with(1); + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([0.0, 0.0, 16000.0, 1600.0])], + overlay: "X".into(), + look: pdfcraft_annot::OverlayLook { size: 2.0, repeat: true, ..Default::default() }, + }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Tester".into() }), + ) + .unwrap(); + let before = s.get(id).unwrap().bytes.clone(); + assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(pdfcraft_redact::RedactError::OverlayLimit)))); + let doc = s.get(id).unwrap(); + assert_eq!(doc.bytes, before); + assert_eq!(doc.redaction_marks(), 1); + assert_eq!(doc.can_undo(), Some("Add redaction mark")); + assert_eq!(page_texts(&s, id), ["Page 1"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); + assert_eq!(page_texts(&s, id), ["Page 1"]); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); diff --git a/crates/redact/README.md b/crates/redact/README.md index 8ff86e9603..3b2d54e814 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -34,6 +34,13 @@ A verification pass re-reads each redacted page and fails the operation if any g image is still under an area. Content that can't be decoded makes the operation fail rather than leave it unredacted. +Before changing any page, `apply` prepares the redaction appearances with a shared 8 MiB +budget for their uncompressed PDF content across the selected pages. It bounds repeated-text +counts and working line buffers before allocating them, and measures and encodes each repeated +line once. Invalid or excessive appearances return `RedactError::OverlayLimit` without changing +the document. Reduce the marked area, disable repeated text, or apply fewer pages at a time. +This limits appearance generation; it is not a limit on the input PDF or the whole process's memory. + Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index ddacfe2345..afdca8e7f4 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -21,6 +21,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod image; mod interp; +mod overlay; pub mod patterns; pub mod sanitize; mod tags; @@ -46,6 +47,10 @@ pub enum RedactError { "page {0} has vertical-writing text while a redaction mark is pending, and PdfCraft cannot tell whether that text lies under the mark yet, so nothing was changed" )] VerticalText(usize), + #[error( + "redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)" + )] + OverlayLimit, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -206,76 +211,6 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< Ok((list, data)) } -/// The boxes and overlay text drawn for the applied marks. -fn overlay_content(marks: &[&Mark]) -> Vec { - let n = |v: f64| { - let s = format!("{:.3}", v); - s.trim_end_matches('0').trim_end_matches('.').to_string() - }; - let mut c: Vec = Vec::new(); - for m in marks { - let Some(fill) = m.fill else { continue }; - c.extend(format!("q {} {} {} rg\n", n(fill[0]), n(fill[1]), n(fill[2])).bytes()); - for r in &m.rects { - c.extend(format!("{} {} {} {} re f\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1])).bytes()); - } - c.extend_from_slice(b"Q\n"); - if m.overlay.is_empty() { - continue; - } - // The overlay text: its font, colour and alignment; auto-sized to fit unless a size is - // set; repeated to fill the area when asked. - let look = &m.look; - // The published standard-14 metrics, so overlay text is measured as it will be drawn. - let (res, face) = match look.font { - pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::Std14::Helvetica), - pdfcraft_annot::OverlayFont::Times => ("PCTimes", pdfcraft_fonts::Std14::TimesRoman), - pdfcraft_annot::OverlayFont::Courier => ("PCCour", pdfcraft_fonts::Std14::Courier), - }; - let width = move |s: &str, size: f64| face.text_width(s, size); - let [cr, cg, cb] = look.color.map(|v| v.clamp(0.0, 1.0)); - for r in &m.rects { - let (w, h) = (r[2] - r[0], r[3] - r[1]); - let mut size = if look.size > 0.0 { look.size } else { (h * 0.7).min(12.0) }; - let tw = width(&m.overlay, size); - if look.size <= 0.0 && tw > w - 2.0 && tw > 0.0 { - size *= (w - 2.0).max(0.0) / tw; - } - if size < 2.0 { - continue; - } - // One line, or as many repeated lines as fit (each line the text repeated across). - let lines: Vec = if look.repeat { - let unit = width(&format!("{} ", m.overlay), size).max(0.01); - let per_line = ((w - 2.0) / unit).floor().max(1.0) as usize; - let count = ((h / (size * 1.2)).floor() as usize).max(1); - vec![vec![m.overlay.as_str(); per_line].join(" "); count] - } else { - vec![m.overlay.clone()] - }; - let block = lines.len() as f64 * size * 1.2; - let mut y = r[1] + (h + block) / 2.0 - size * 0.95; - c.extend( - format!("q {} {} {} {} re W n BT {} {} {} rg /{res} {} Tf ", n(r[0]), n(r[1]), n(w), n(h), n(cr), n(cg), n(cb), n(size)).bytes(), - ); - for line in &lines { - let lw = width(line, size); - let x = match look.align { - 0 => r[0] + 1.0, - 2 => r[2] - 1.0 - lw, - _ => r[0] + (w - lw) / 2.0, - }; - c.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - c.extend_from_slice(&pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(line))); - c.extend_from_slice(b" Tj "); - y -= size * 1.2; - } - c.extend_from_slice(b"ET Q\n"); - } - } - c -} - /// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them): /// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or /// a tree deeper than any real one), so callers can stay conservative. @@ -384,9 +319,16 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = chosen.iter().map(|m| m.page).collect(); - by_page.sort_unstable(); - by_page.dedup(); + // `marks` visits pages in order. Keep slices of that order for appearance generation, + // content rewriting and verification instead of scanning all marks for every page. + let groups: Vec<_> = chosen.chunk_by(|a, b| a.page == b.page).filter_map(|group| group.first().map(|mark| (mark.page, group))).collect(); + let by_page: Vec<_> = groups.iter().map(|(page, _)| *page).collect(); + // Budget failures must leave even a direct library caller's document untouched. + let mut remaining = overlay::MAX_BYTES; + let overlays = groups + .iter() + .map(|&(pi, page_marks)| overlay::content(page_marks, &mut remaining).map(|content| (pi, content))) + .collect::, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; let mut doomed_fields: Vec = Vec::new(); @@ -395,9 +337,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); - for &pi in &by_page { + for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); - let page_marks: Vec<&Mark> = chosen.iter().copied().filter(|m| m.page == pi).collect(); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); // 1. Content. @@ -558,9 +499,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = chosen.iter().copied().filter(|m| m.page == pi).collect(); - let content = overlay_content(&page_marks); + for (pi, content) in overlays { if !content.is_empty() { pdfcraft_edit::stamp(doc, pi, "Redaction", content)?; } @@ -572,9 +511,9 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = chosen.iter().filter(|m| m.page == pi).flat_map(|m| m.rects.iter().copied()).collect(); + let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); let (list, data) = page_streams(doc, &page.dict, pi)?; // The overlay stream (last) draws text of its own: the overlay label is allowed. let original: Vec> = list diff --git a/crates/redact/src/overlay.rs b/crates/redact/src/overlay.rs new file mode 100644 index 0000000000..fc8fd15994 --- /dev/null +++ b/crates/redact/src/overlay.rs @@ -0,0 +1,129 @@ +//! Prepare bounded redaction appearances before the document is changed. + +use std::borrow::Cow; + +use super::{Mark, RedactError}; + +/// Shared by every selected page, including fills, text and PDF operators. +pub(super) const MAX_BYTES: usize = 8 * 1024 * 1024; + +struct Output<'a> { + bytes: Vec, + remaining: &'a mut usize, +} + +impl Output<'_> { + fn push(&mut self, bytes: impl AsRef<[u8]>) -> Result<(), RedactError> { + let bytes = bytes.as_ref(); + let left = self.remaining.checked_sub(bytes.len()).ok_or(RedactError::OverlayLimit)?; + self.bytes.try_reserve(bytes.len()).map_err(|_| RedactError::OverlayLimit)?; + self.bytes.extend_from_slice(bytes); + *self.remaining = left; + Ok(()) + } +} + +fn repeat_line(text: &str, across: f64, down: f64, remaining: usize) -> Result<(String, usize), RedactError> { + // Check floating-point counts before casting, and the product before allocating. + if !across.is_finite() || !down.is_finite() || across > remaining as f64 || down > remaining as f64 { + return Err(RedactError::OverlayLimit); + } + let (across, down) = (across as usize, down as usize); + let bytes = text.len().checked_add(1).and_then(|n| n.checked_mul(across)).and_then(|n| n.checked_sub(1)); + let bytes = bytes.filter(|n| *n <= remaining).ok_or(RedactError::OverlayLimit)?; + let mut line = String::new(); + line.try_reserve_exact(bytes).map_err(|_| RedactError::OverlayLimit)?; + for i in 0..across { + if i > 0 { + line.push(' '); + } + line.push_str(text); + } + Ok((line, down)) +} + +pub(super) fn content(marks: &[&Mark], remaining: &mut usize) -> Result, RedactError> { + let n = |v: f64| { + let s = format!("{:.3}", v); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + let mut c = Output { bytes: Vec::new(), remaining }; + for m in marks { + let Some(fill) = m.fill else { continue }; + if !fill.iter().all(|v| v.is_finite()) || m.rects.iter().flatten().any(|v| !v.is_finite()) { + return Err(RedactError::OverlayLimit); + } + c.push(format!("q {} {} {} rg\n", n(fill[0]), n(fill[1]), n(fill[2])))?; + for r in &m.rects { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + if !w.is_finite() || !h.is_finite() { + return Err(RedactError::OverlayLimit); + } + c.push(format!("{} {} {} {} re f\n", n(r[0]), n(r[1]), n(w), n(h)))?; + } + c.push(b"Q\n")?; + if m.overlay.is_empty() { + continue; + } + if m.overlay.len() > *c.remaining { + return Err(RedactError::OverlayLimit); + } + let look = &m.look; + if !look.size.is_finite() || !look.color.iter().all(|v| v.is_finite()) { + return Err(RedactError::OverlayLimit); + } + // Keep upstream's standard-14 metrics when preparing the bounded appearance. + let (res, face) = match look.font { + pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::Std14::Helvetica), + pdfcraft_annot::OverlayFont::Times => ("PCTimes", pdfcraft_fonts::Std14::TimesRoman), + pdfcraft_annot::OverlayFont::Courier => ("PCCour", pdfcraft_fonts::Std14::Courier), + }; + let width = move |s: &str, size: f64| face.text_width(s, size); + let [cr, cg, cb] = look.color.map(|v| v.clamp(0.0, 1.0)); + for r in &m.rects { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + let mut size = if look.size > 0.0 { look.size } else { (h * 0.7).min(12.0) }; + let tw = width(&m.overlay, size); + if look.size <= 0.0 && tw > w - 2.0 && tw > 0.0 { + size *= (w - 2.0).max(0.0) / tw; + } + if size < 2.0 { + continue; + } + let (line, count) = if look.repeat { + let unit = width(&format!("{} ", m.overlay), size).max(0.01); + let across = ((w - 2.0) / unit).floor().max(1.0); + let down = (h / (size * 1.2)).floor().max(1.0); + let (line, count) = repeat_line(&m.overlay, across, down, *c.remaining)?; + (Cow::Owned(line), count) + } else { + (Cow::Borrowed(m.overlay.as_str()), 1) + }; + // Every row is identical: measure and encode it once, without a Vec of copies. + let encoded = pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&line)); + if encoded.len().checked_add(4).and_then(|n| n.checked_mul(count)).is_none_or(|n| n > *c.remaining) { + return Err(RedactError::OverlayLimit); + } + let lw = width(&line, size); + let x = match look.align { + 0 => r[0] + 1.0, + 2 => r[2] - 1.0 - lw, + _ => r[0] + (w - lw) / 2.0, + }; + let block = count as f64 * size * 1.2; + let mut y = r[1] + (h + block) / 2.0 - size * 0.95; + if !lw.is_finite() || !x.is_finite() || !y.is_finite() { + return Err(RedactError::OverlayLimit); + } + c.push(format!("q {} {} {} {} re W n BT {} {} {} rg /{res} {} Tf ", n(r[0]), n(r[1]), n(w), n(h), n(cr), n(cg), n(cb), n(size)))?; + for _ in 0..count { + c.push(format!("1 0 0 1 {} {} Tm ", n(x), n(y)))?; + c.push(&encoded)?; + c.push(b" Tj ")?; + y -= size * 1.2; + } + c.push(b"ET Q\n")?; + } + } + Ok(c.bytes) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index dd319846f8..9b09dabb57 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -685,6 +685,141 @@ fn overlay_text_takes_its_font_size_colour_alignment_and_repeats() { assert!(fonts.contains(b"PCCour") && !fonts.contains(b"PCTimes")); } +fn repeated_overlay_document(width: f64, height: f64, repeat: bool) -> Document { + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Annots [5 0 R] >>".to_vec(), + stream("", b"0 0 20 20 re f"), + format!("<< /Type /Annot /Subtype /Redact /Rect [0 0 {width} {height}] /IC [0 0 0] /OverlayText (X) /DA (/Helv 2 Tf) /Repeat {repeat} >>") + .into_bytes(), + ]) +} + +#[test] +fn oversized_repeated_overlay_is_refused_before_changing_the_document() { + // About 11 MiB of generated content before the fix; a bounded reproduction, not an OOM test. + let mut doc = repeated_overlay_document(16000.0, 1600.0, true); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(apply(&mut doc, None), Err(RedactError::OverlayLimit)); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); + assert_eq!(marks(&doc).len(), 1); + let mut single = repeated_overlay_document(16000.0, 1600.0, false); + apply(&mut single, None).unwrap(); + assert_eq!(content(&reopen(&single), 0).matches("(X) Tj").count(), 1); +} + +#[test] +fn overlay_budget_is_shared_by_pages_and_respects_the_page_selection() { + let mut doc = repeated_overlay_document(8000.0, 800.0, true); + let page = (*doc.get(ObjRef::new(3, 0))).clone(); + let mut kids = vec![Object::Ref(ObjRef::new(3, 0))]; + for _ in 0..2 { + let mut page = page.as_dict().unwrap().clone(); + let annotation = doc.add((*doc.get(ObjRef::new(5, 0))).clone()); + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation)])); + kids.push(Object::Ref(doc.add(Object::Dict(page)))); + } + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(kids)); + d.set(b"Count".to_vec(), Object::Int(3)); + }) + .unwrap(); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + // Each appearance fits on its own; their sum exceeds the operation's budget. + assert_eq!(apply(&mut doc, None), Err(RedactError::OverlayLimit)); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); + assert_eq!(marks(&doc).len(), 3); + apply(&mut doc, Some(&[1])).unwrap(); + assert_eq!(marks(&doc).iter().map(|m| m.page).collect::>(), [0, 2]); +} + +#[test] +fn grouped_marks_keep_overlay_order_and_ignore_duplicate_page_selections() { + let mut doc = repeated_overlay_document(60.0, 30.0, false); + let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let original_mark = (*doc.get(ObjRef::new(5, 0))).clone(); + let mut kids = vec![Object::Ref(ObjRef::new(3, 0))]; + for _ in 0..2 { + let mut copy = page.clone(); + let mark = doc.add(original_mark.clone()); + copy.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(mark)])); + kids.push(Object::Ref(doc.add(Object::Dict(copy)))); + } + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(kids)); + d.set(b"Count".to_vec(), Object::Int(3)); + }) + .unwrap(); + let mut second = original_mark.as_dict().unwrap().clone(); + second.set(b"OverlayText".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"Y".to_vec()))); + let second = doc.add(Object::Dict(second)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(5, 0)), Object::Ref(second)])); + }) + .unwrap(); + let untouched = content(&doc, 1); + let report = apply(&mut doc, Some(&[2, 0, 2])).unwrap(); + assert_eq!((report.pages, report.marks), (2, 3)); + let saved = reopen(&doc); + assert_eq!(marks(&saved).iter().map(|m| m.page).collect::>(), [1]); + assert_eq!(content(&saved, 1), untouched); + let first = content(&saved, 0); + assert!(first.find("(X) Tj").unwrap() < first.find("(Y) Tj").unwrap()); + assert_eq!(content(&saved, 2).matches("(X) Tj").count(), 1); +} + +#[test] +fn overlay_extreme_counts_and_invalid_geometry_are_refused_without_large_allocations() { + let doc = repeated_overlay_document(20.0, 10.0, true); + let original = marks(&doc).remove(0); + for rect in [[0.0, 0.0, 1e100, 10.0], [0.0, 0.0, 20.0, 1e100], [-f64::MAX, 0.0, f64::MAX, 10.0], [0.0, 0.0, f64::NAN, 10.0]] { + let mut mark = original.clone(); + mark.rects = vec![rect]; + let mut remaining = overlay::MAX_BYTES; + assert_eq!(overlay::content(&[&mark], &mut remaining), Err(RedactError::OverlayLimit)); + } +} + +#[test] +fn repeated_overlay_keeps_its_exact_appearance_and_counts_escaped_bytes() { + let doc = repeated_overlay_document(20.0, 10.0, true); + let mut mark = marks(&doc).remove(0); + let expected = b"q 0 0 0 rg\n0 0 20 10 re f\nQ\nq 0 0 20 10 re W n BT 1 0 0 rg /PCHelv 2 Tf 1 0 0 1 1.773 7.9 Tm (X X X X X X X X X) Tj 1 0 0 1 1.773 5.5 Tm (X X X X X X X X X) Tj 1 0 0 1 1.773 3.1 Tm (X X X X X X X X X) Tj 1 0 0 1 1.773 0.7 Tm (X X X X X X X X X) Tj ET Q\n"; + let mut remaining = expected.len(); + assert_eq!(overlay::content(&[&mark], &mut remaining).unwrap(), expected); + assert_eq!(remaining, 0); + mark.overlay = "é(\\)".into(); + let mut remaining = overlay::MAX_BYTES; + let encoded = overlay::content(&[&mark], &mut remaining).unwrap(); + let escaped = b"\xe9\\(\\\\\\)"; + assert!(encoded.windows(escaped.len()).any(|w| w == escaped)); + assert_eq!(overlay::MAX_BYTES - remaining, encoded.len()); + let mut too_small = encoded.len() - 1; + assert_eq!(overlay::content(&[&mark], &mut too_small), Err(RedactError::OverlayLimit)); +} + +#[test] +#[ignore = "manual overlay timing probe"] +fn repeated_overlay_timing_probe() { + use std::hash::{Hash, Hasher}; + for (w, h) in [(20.0, 10.0), (2000.0, 200.0), (4000.0, 400.0), (8000.0, 800.0)] { + let doc = repeated_overlay_document(w, h, true); + let marks = marks(&doc); + let refs: Vec<_> = marks.iter().collect(); + let start = std::time::Instant::now(); + let mut bytes = Vec::new(); + for _ in 0..20 { + let mut remaining = overlay::MAX_BYTES; + bytes = std::hint::black_box(overlay::content(std::hint::black_box(&refs), &mut remaining).unwrap()); + } + let elapsed = start.elapsed().as_secs_f64() * 1000.0 / 20.0; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hash); + println!("{w}x{h}: bytes={} hash={} ms={elapsed:.3}", bytes.len(), hash.finish()); + } +} + /// Overlay text the standard fonts can't draw: refused when it is marked, and for a mark another /// program made, refused when it is applied, before anything is redacted, rather than burnt into /// the page as `?`. From b89cbd004488a372a3e2ac204fe92c8fb36e0b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Bartalos?= Date: Thu, 15 Oct 2026 08:17:06 +0200 Subject: [PATCH 015/207] M4: keep the last saved revision across undo (#1310, #1311, #1312) (#1435) * M4: keep the last saved revision across undo (#1310, #1311, #1312) Undo treated a return to the opened file as unsaved work, and after a save it followed the restored snapshot's older base. Revert and the next save now stay on the last saved file, so that revision remains in the PDF. * Run a tool after a background open of the file it asked for A tool chosen with no document opened the picked file on the worker, but only ran when that open finished inside the picker callback. Remember the command on the pending open, and let the command test wait for the worker. * Wait for background opens in the command tests Opening a picked file or a recent file finishes on the worker, so a fixed handful of frames can assert before the tab exists. * Compare batch copies to the PDF that was written A new text PDF stamps the current second, so a later pdf() call is a different file. The numbered copy and the source must match the bytes that were saved. * Drop the unused batch fixture left by the merge Main already keeps the written PDF in original, so the older source binding is unread and fails -D warnings. --- ROADMAP.md | 2 + crates/cos/src/document.rs | 43 +++++++++++++++++ crates/engine/src/lib.rs | 60 ++++++++++++++++++----- crates/engine/src/tests.rs | 81 +++++++++++++++++++++++++++++++- crates/ui-egui/tests/commands.rs | 1 - docs/roadmap.md | 4 +- 6 files changed, 175 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index baedd7fbbe..ab5361e60d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -152,6 +153,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. +- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0f05661b26..f58c12cb5f 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -914,6 +914,49 @@ impl Document { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); } + /// Record `current`'s object graph as unsaved edits of this document. + /// + /// Used when the working snapshot was restored from an earlier revision and the next + /// incremental save must still append to this file (the last saved revision). Objects + /// that already match are left untouched. Reads go through private readers so neither + /// document's editing cache is filled. + pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { + let saved_nums = self.object_numbers(); + let current_nums = current.object_numbers(); + let mut freed = Vec::new(); + let mut changed = Vec::new(); + { + let mut saved_reader = self.object_reader(); + let mut current_reader = current.object_reader(); + for &num in &saved_nums { + if current_nums.binary_search(&num).is_err() { + freed.push(num); + } + } + for &num in ¤t_nums { + let generation = current.generation(num); + let same = saved_nums.binary_search(&num).is_ok() + && self.generation(num) == generation + && saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?; + if !same { + changed.push(num); + } + } + } + for num in freed { + let generation = self.generation(num); + self.free(ObjRef::new(num, generation)); + } + let mut current_reader = current.object_reader(); + for num in changed { + let generation = current.generation(num); + let object = current_reader.try_get(ObjRef::new(num, 0))?; + self.set(ObjRef::new(num, generation), (*object).clone()); + } + *self.trailer_mut() = current.trailer().clone(); + Ok(()) + } + /// Edit a dictionary (or stream dictionary) object in place. pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> { let mut obj = (*self.get(r)).clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fbe62b2c2a..651cd21af9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -247,6 +247,11 @@ type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); #[derive(Clone)] struct Editor { cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + saved: Arc>, + /// Passwords that open [`Self::saved`]. + saved_keys: Keys, undo: Vec, redo: Vec, keys: Keys, @@ -743,9 +748,9 @@ impl Document { } /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. + /// to. This stays that file when undo has restored an older snapshot. pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.cos.bytes().clone()) + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) } /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). @@ -3205,9 +3210,12 @@ impl Session { let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); ( Some(Editor { cos, + saved, + saved_keys: keys.clone(), undo: Vec::new(), redo: Vec::new(), keys, @@ -3483,7 +3491,7 @@ impl Session { editor.redo.push((label.clone(), current, current_keys, scope)); Self::adopt_keys(doc); Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?; - doc.dirty = true; + Self::sync_dirty(doc); doc.generation += 1; Ok(label) } @@ -3498,11 +3506,18 @@ impl Session { editor.undo.push((label.clone(), current, current_keys, scope)); Self::adopt_keys(doc); Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?; - doc.dirty = true; + Self::sync_dirty(doc); doc.generation += 1; Ok(label) } + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + /// Use the current state's password for viewing (protection edits change it). fn adopt_keys(doc: &mut Document) { if let Some(e) = doc.editor.as_ref() { @@ -3723,11 +3738,27 @@ impl Session { pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - if !editor.cos.is_modified() { - return Ok(editor.cos.bytes().clone()); + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved { + return Ok(editor.saved.clone()); } let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_incremental(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + guard(|| { + if on_saved || rewrite { + write_incremental(&editor.cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&editor.cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) } /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). @@ -3758,8 +3789,11 @@ impl Session { if let Some(editor) = doc.editor.as_mut() { // An encrypted file needs the password it is protected with now (the owner // password when known, so saving never downgrades this session's rights). - editor.cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()) - .map_err(|e| EditError::Reopen(e.to_string()))?; + let keys = editor.keys.clone(); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + editor.saved = bytes; + editor.saved_keys = keys; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); @@ -3808,10 +3842,10 @@ impl Session { pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.cos.bytes().clone(); - // The saved file opens with the passwords of the state that was saved: try the current - // ones, then each earlier state's. - let mut candidates = vec![editor.keys.clone()]; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); candidates.push(Keys::default()); let (cos, keys) = candidates diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5e1a288b54..99594cde25 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -472,12 +472,85 @@ fn undo_still_works_after_save() { s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); let saved = s.save_bytes(id).unwrap(); s.mark_saved(id, saved, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty, "undo back to the saved file is clean"); + assert_eq!(doc.info.pages.len(), 1); + assert_eq!(doc.info.pages[0].rotation, 0); s.undo(id).unwrap(); let doc = s.get(id).unwrap(); assert!(doc.dirty, "undoing past a save makes the document dirty again"); assert_eq!(doc.info.pages.len(), 2); } +#[test] +fn undo_to_the_opened_file_is_not_dirty() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let opened = Arc::new(fixture(2)); + let id = session.open("existing.pdf", None, opened.clone(), None).unwrap(); + assert!(!session.get(id).unwrap().dirty); + assert!(session.autosave_snapshots().is_empty()); + + session.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + assert_eq!(session.get(id).unwrap().info.pages.len(), 1); + session.undo(id).unwrap(); + let document = session.get(id).unwrap(); + assert!(!document.dirty, "the opened bytes are the saved version"); + assert_eq!(document.bytes, opened); + assert_eq!(document.info.pages.len(), 2); + assert!(session.autosave_snapshots().is_empty(), "nothing unsaved to recover"); + + session.redo(id).unwrap(); + assert!(session.get(id).unwrap().dirty); + session.undo(id).unwrap(); + assert!(!session.get(id).unwrap().dirty); +} + +#[test] +fn save_after_undo_retains_the_latest_saved_revision() { + let (mut session, document) = session_with(3); + session.apply(document, Edit::DeletePages { pages: vec![1] }).unwrap(); + let deleted = session.save_bytes(document).unwrap(); + session.mark_saved(document, deleted, None).unwrap(); + session.apply(document, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let rotated = session.save_bytes(document).unwrap(); + session.mark_saved(document, rotated.clone(), None).unwrap(); + let rotated_revisions = pdfcraft_cos::Document::open(rotated.clone()).unwrap().revisions().len(); + + session.undo(document).unwrap(); + session.apply(document, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); + let saved = session.save_bytes(document).unwrap(); + assert!(saved.starts_with(rotated.as_slice()), "the saved rotation stays in the file"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.revisions().len() > rotated_revisions, "the title is a newer revision, not a rewrite of the earlier one"); + assert_eq!(pdfcraft_organize::info(&reopened, "Title").as_deref(), Some("Report")); + + session.mark_saved(document, saved, None).unwrap(); + let doc = session.get(document).unwrap(); + assert_eq!(page_texts(&session, document), ["Page 1", "Page 3"]); + assert_eq!(doc.info.pages[0].rotation, 0, "the undone rotation is the current page"); + assert_eq!(doc.info_value("Title").as_deref(), Some("Report")); +} + +#[test] +fn revert_preserves_the_saved_pages_after_undo() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved_bytes = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved_bytes, None).unwrap(); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().dirty); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); + + s.revert(id).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none()); + assert_eq!(page_texts(&s, id), ["Page 2"], "the last saved pages, not the file as opened"); + assert_eq!(d.info.pages[0].rotation, 90); +} + #[test] fn full_save_is_readable_and_smaller_after_deletes() { let (mut s, id) = session_with(5); @@ -1113,8 +1186,14 @@ fn autosave_snapshots_only_changed_documents() { assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(s.autosave_snapshots().len(), 1); s.undo(id).unwrap(); - assert_eq!(s.autosave_snapshots().len(), 1, "undo is a change too"); + assert!(s.get(id).unwrap().dirty, "the rotation is still unsaved"); + assert_eq!(s.autosave_snapshots().len(), 1, "undo that leaves unsaved work is a change"); + s.undo(id).unwrap(); + assert!(!s.get(id).unwrap().dirty, "undo back to the opened file is saved"); + assert!(s.autosave_snapshots().is_empty(), "a clean document needs no recovery"); let saved = s.save_bytes(id).unwrap(); s.mark_saved(id, saved, None).unwrap(); assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery"); diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index a8b9d88f04..23fbedae02 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -296,7 +296,6 @@ fn the_open_recent_menu_lists_files_and_opens_one() { h.get_by_label_contains("recent.pdf").click(); support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none()); let app = h.state(); - assert_eq!(app.views.len(), 2, "the recent file opened in a new tab"); let active = app.active.unwrap(); assert_eq!(app.session.get(app.views[active].id).and_then(|d| d.path.as_deref()), Some(path.as_str()), "the active tab is the recent file"); assert_eq!(app.session.get(app.views[active].id).unwrap().bytes.as_slice(), fixture(2).as_slice()); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9b9a39b62d..3342f83d81 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -95,7 +96,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -162,6 +163,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | From 3d7336fc41ac2718171519821a279b89e7a3f03c Mon Sep 17 00:00:00 2001 From: Luchsen Date: Thu, 15 Oct 2026 08:18:03 +0200 Subject: [PATCH 016/207] M5: add comment and reply submission shortcuts (#1457) Submit highlight comment text and replies with Ctrl+Enter, retain Command+Enter for floating composers, and let Shift+Enter insert reply newlines. Keep ordinary Enter-to-post for replies and restrict submission to the focused editor. Add UI regressions and parity evidence. Normalize existing lint and formatting issues and pin the reload test's English labels so the required checks pass on this Windows machine. Co-authored-by: Armin Reisgys Co-authored-by: OpenAI Codex Co-authored-by: Luchsen <210128845+Luchsen@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 5 ++ crates/forms/src/af.rs | 6 +- crates/ui-egui/src/comments.rs | 23 +++++- crates/ui-egui/src/comments_panel.rs | 10 ++- crates/ui-egui/tests/comments.rs | 105 +++++++++++++++++++++++++++ crates/ui-egui/tests/reload.rs | 1 + docs/roadmap.md | 5 +- parity/acrobat-features.toml | 12 +-- 8 files changed, 152 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ab5361e60d..37ffaefa02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,7 @@ > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5: comment submission shortcuts; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -151,6 +152,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. +- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. + - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. @@ -165,6 +168,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. - **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged. - **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. +- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged. + - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index be38a9546f..a3eea73236 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -624,10 +624,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { match k { // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" // for "dd.mm.26"); a different number there is left over and rejected below. - '#' => { - if it.peek() == Some(&literal) { - it.next(); - } + '#' if it.peek() == Some(&literal) => { + it.next(); } 'm' => { dt.m = match month_name { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 774e37b30f..6f3d8ab876 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1210,7 +1210,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, edit.request_focus(); c.focus = false; } - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); + let enter = submit_key(ui, &edit, false); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { cancel = true; } @@ -1288,6 +1288,27 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, } } +/// Submit only from the focused comment editor. Match each key event's modifiers exactly, +/// so Shift+Enter stays a newline and another editor cannot submit this draft. Replies also +/// keep their existing unmodified Enter-to-post behavior. +pub(crate) fn submit_key(ui: &mut egui::Ui, field: &egui::Response, plain_enter: bool) -> bool { + field.has_focus() + && ui.input_mut(|input| { + let mut submit = false; + input.events.retain(|event| { + let matched = matches!(event, + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } + if modifiers.matches_exact(egui::Modifiers::CTRL) + || modifiers.matches_exact(egui::Modifiers::COMMAND) + || plain_enter && modifiers.matches_exact(egui::Modifiers::NONE) + ); + submit |= matched; + !matched + }); + submit + }) +} + /// The side of the caret's box, in user space units, away from text. const CARET_SIZE: f64 = 8.0; diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 901493adf8..32ea870b0a 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -299,10 +299,12 @@ fn card( if !r.has_focus() && !ui.memory(|m| m.focused().is_some()) { r.request_focus(); } + let submit = crate::comments::submit_key(ui, &r, false); ui.horizontal(|ui| { - if ui + if (ui .add(egui::Button::new(egui::RichText::new(tl!("Save")).color(Color32::WHITE)).fill(t.accent).corner_radius(12)) .clicked() + || submit) && let Some((page, index, text)) = view.comments.editing.take() { edit = Some(Edit::SetAnnotationContents { page, index, text }); @@ -359,12 +361,14 @@ fn card( // around the button can't fit every language's "Post" exactly, and any overflow // would widen the resizable panel again on every repaint. let r = ui.add( - crate::widgets::line(&mut view.comments.reply) + crate::widgets::block(&mut view.comments.reply) + .desired_rows(1) + .return_key(egui::KeyboardShortcut::new(egui::Modifiers::SHIFT, egui::Key::Enter)) .hint_text(tl!("Add a reply")) .desired_width(f32::INFINITY) .id_salt(("comment-reply", key)), ); - let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let enter = crate::comments::submit_key(ui, &r, true); let ok = !view.comments.reply.trim().is_empty(); let post = ui.with_layout(Layout::right_to_left(Align::Min), |ui| ui.add_enabled(ok, egui::Button::new(tl!("Post")).corner_radius(12))); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 98f2bf9071..18539535ec 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -231,6 +231,111 @@ fn a_new_highlight_opens_its_note_for_typing() { assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "typed".into()))); } +#[test] +fn ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line() { + let mut h = harness(|app| { + app.set_option("quick", "highlight").unwrap(); + app.set_option("panel", "comments").unwrap(); + }); + let (a, b) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph")) + }; + drag(&mut h, a, b); + h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply")) + .next() + .expect("highlight editor") + .click(); + h.run_steps(2); + h.event(egui::Event::Text("First line".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.editing.as_ref().unwrap().2, "First line\n"); + assert_eq!(comments(&h)[0].contents.as_deref().unwrap_or(""), "", "a newline does not post"); + h.event(egui::Event::Text("Second line".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.editing, None); + assert_eq!(comments(&h)[0].contents.as_deref(), Some("First line\nSecond line")); + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(comments(&h)[0].contents.as_deref().unwrap_or(""), "", "posting is one undo step"); +} + +#[test] +fn ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line() { + let mut h = harness(|app| app.set_option("panel", "comments").unwrap()); + field(&h, "Add a comment").click(); + h.run_steps(2); + field(&h, "Add a comment").type_text("Parent"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + field(&h, "Add a reply").click(); + h.run_steps(2); + field(&h, "Add a reply").type_text("First line"); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter); + h.run_steps(2); + assert_eq!(comments(&h).len(), 1, "Shift+Enter does not post a reply"); + assert_eq!(h.state().views[0].comments.reply, "First line\n"); + h.event(egui::Event::Text("Second line".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 2); + assert_eq!(c[1].contents.as_deref(), Some("First line\nSecond line")); + assert_eq!(c[1].in_reply_to, c[0].name); + assert_eq!(c[1].author.as_deref(), Some("Tester")); + assert!(h.state().views[0].comments.reply.is_empty()); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(2); + assert_eq!(comments(&h).len(), 2, "an empty reply is not posted"); + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(comments(&h).len(), 1, "posting is one undo step"); + h.state_mut().set_option("comment", "1:1").unwrap(); + h.run_steps(2); + field(&h, "Add a reply").click(); + h.run_steps(2); + field(&h, "Add a reply").type_text("Enter still posts"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(comments(&h)[1].contents.as_deref(), Some("Enter still posts")); +} + +#[test] +fn the_comment_composer_shortcut_only_posts_the_focused_editor() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").click(); + h.run_steps(2); + h.event(egui::Event::Text("First line".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.composer.as_ref().unwrap().text, "First line\n"); + assert!(comments(&h).is_empty()); + h.event(egui::Event::Text("Second line".into())); + h.run_steps(2); + h.query_all_by_value("1").next().expect("current page input").focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(2); + assert!(h.state().views[0].comments.composer.is_some(), "a shortcut outside the editor keeps the draft"); + assert!(comments(&h).is_empty()); + h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).next().expect("composer editor").focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(3); + assert!(h.state().views[0].comments.composer.is_none()); + assert_eq!(comments(&h)[0].contents.as_deref(), Some("First line\nSecond line")); +} + #[test] fn a_selection_made_first_is_marked_when_a_tool_is_picked() { let mut h = harness(|_| {}); diff --git a/crates/ui-egui/tests/reload.rs b/crates/ui-egui/tests/reload.rs index dc0ed869bb..03a3bf5f41 100644 --- a/crates/ui-egui/tests/reload.rs +++ b/crates/ui-egui/tests/reload.rs @@ -77,6 +77,7 @@ fn harness(path: &str) -> Harness<'static, PdfCraftApp> { let path = path.to_owned(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.run_inline = true; app.open_path(&path); app diff --git a/docs/roadmap.md b/docs/roadmap.md index 3342f83d81..b67ef67f46 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,6 +9,7 @@ > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5: comment submission shortcuts; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: inline Arabic ActualText ligatures join their word; earlier: preserve Arabic ActualText typing order; M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -99,7 +100,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | @@ -164,6 +165,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | +| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | @@ -182,6 +184,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. | | 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. | | 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. | +| 2026-10-12 | minor | M5: Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds newlines. Estimates unchanged. | | 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, no other animations. Preferences ▸ Animation effects (Use system setting, On, Off) overrides it. macOS, Linux and the browser aren't asked yet. Estimates unchanged. | | 2026-10-11 | minor | M10: printing on the web through the browser's print dialog (gap 4 closed); rank 1 drops web printing, 65–120 → 50–90 h | | 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3f56e207ad..93f62a4cac 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3548,8 +3548,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "our own Comment/Note icon drawings, with a pop-up" +evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] +notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline" [[feature]] id = "comment.highlight" @@ -3896,8 +3896,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_reply"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] -notes = "" +evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] +notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted" [[feature]] id = "comment.set-status" @@ -3944,8 +3944,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools"] -notes = "double-click a comment or card, or Edit in the menus; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"] +notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" [[feature]] id = "comment.copy-comment-text" From 0b732e90ff634a431a3da46bef25db7bbaf42784 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Thu, 15 Oct 2026 08:18:13 +0200 Subject: [PATCH 017/207] M12: mark leaf tags as artifacts (#1475) * M12: mark leaf tags as artifacts * chore: refresh translation asset hashes * fix(a11y): retain text autotag exports after artifact merge --- ATTRIBUTION.toml | 42 +++++------ crates/a11y/src/alt.rs | 104 ++++++++++++++++++++++++++ crates/a11y/src/lib.rs | 2 +- crates/a11y/src/tests.rs | 16 ++++ crates/automation/src/a11y.rs | 12 +++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 5 ++ crates/automation/tests/automation.rs | 34 +++++++++ crates/engine/src/lib.rs | 9 +++ crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/lt.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/tags_ui.rs | 8 +- crates/ui-egui/tests/accessibility.rs | 21 ++++++ parity/acrobat-features.toml | 10 +-- 33 files changed, 299 insertions(+), 28 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 07433fc453..6715f7b4ce 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53" +sha256 = "1fd7a303d0c256893a3201eee403386f1501a5706af5903e843e9f5a8449d619" # 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 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91" +sha256 = "e3e896ca45a954406a235b66085de6a3e6c8209a1d2a1164ac8ae88a1562cdfb" [[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 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad" +sha256 = "d7de75508c3637033c93886b742245f903a6f806bab208ff295bf57e7707e7bb" [[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 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7" +sha256 = "ffb55f4e78a7dacb6d76bc36f1f5e593b889d5f6680f1c53c8818b1b9cdce501" [[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 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0" +sha256 = "471209d1828dafb11c117260ff53616d580cec02fecd37f6685ba7f200b2549e" [[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 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22" +sha256 = "fbdf46be923e9cc88cb62019108239d2a47fdd3cc54e87f30a70ad9497364180" [[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 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f" +sha256 = "e0f031f7ec4c30e9750575792ae18920a0a42ad215f1c394632b52ca308ca65f" [[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 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9" +sha256 = "4a3bbef4db399b02ec62804e4614a2dec42942f90da613854590cbc4b06ea082" [[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 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129" +sha256 = "41ab9886af95cc845ff8d7d89f2794e3663f5682c21e9f553480edb5499f5abf" [[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 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3" +sha256 = "1ecc78878e5c5151f1afc4be2cb15ba05095d35119a2bfadac02b0b921c52df4" [[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 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692" +sha256 = "1e30d02a94f554abf24f23e84242bcc04688de5e3631873147504f900568d13a" [[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 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b" +sha256 = "ae04d6fab0f225d01de7907d70dc58a9135d65f127f845c553344bcfd08fcd6d" [[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 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e" +sha256 = "7ff4cd557d7d3ee1ada2d667653db578bae8964f021366cb11d9d03f81d8cb32" [[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 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9" +sha256 = "fefc14be320cc7625b1c0a02cc26f27722b5d73052ef2d6056f3bb07363e5888" [[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 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e" +sha256 = "6590241c4156d0da89011948e5fd65b363916d7724390d854d19cd21d3e9f440" [[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 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3" +sha256 = "12f0cfe67ad61741ebb96668137eb62a520ef3c4bb7961d451911c73d58ae0b8" [[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 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d" +sha256 = "5db1450bf0e71db21c9bcb994b4e7a529106ae52ffae2d6118997295e0ac8517" [[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 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912" +sha256 = "b34a98f97fb5ceb8f602ab005e859b070d72d73fd3def5a73a3f9eb6915a4d28" [[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 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5" +sha256 = "17b984f557eb36908baf06880a3b540eadc39910283b37e0ae905175db320f45" [[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 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64" +sha256 = "2daa34aacddccdbe9862932c5c6f0a0d8bdb86b07cc385a82f0ceaf9f1c4f333" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ 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 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288" +sha256 = "25f947c54e32c9bc7fb0411ade704fc464fbee7f8a73155f4cf23516311547ce" diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 1f2035405b..21bd8448d8 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -27,6 +27,31 @@ pub enum AltError { Cos(#[from] pdfcraft_cos::CosError), } +/// Why a structure tag cannot safely become an artifact. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum ArtifactError { + #[error("the document has no tags")] + Untagged, + #[error("object {0} is not a reachable indirect structure tag")] + NotATag(u32), + #[error("the tag has child structure elements; mark a leaf tag instead")] + HasChildren, + #[error("the tag has no direct marked content")] + NoMarkedContent, + #[error("the tag has no page")] + NoPage, + #[error("the tag's marked content was not found on its page")] + ContentNotFound, + #[error("the tag has no removable structure-tree parent")] + NoParent, + #[error("the parent must list the tag exactly once")] + AmbiguousParent, + #[error("couldn't update the parent tree: {0}")] + ParentTree(String), + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + pub(crate) fn tree_root(doc: &Document) -> Option { let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?; cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) @@ -305,6 +330,85 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> Ok(()) } +/// Mark a leaf structure tag's directly owned marked content as an artifact. +/// +/// This deliberately refuses a tag with child structure elements. Converting a parent would +/// otherwise silently discard semantic descendants. The visible page content is preserved: each +/// matching marked-content sequence changes from `BDC` with an MCID to `/Artifact BMC`. +pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), ArtifactError> { + tree_root(doc).ok_or(ArtifactError::Untagged)?; + let elements = elements(doc); + let Some(element) = elements.iter().find(|e| e.object == obj) else { return Err(ArtifactError::NotATag(obj.num)) }; + if has_child_structure(doc, &element.dict) { + return Err(ArtifactError::HasChildren); + } + let ids: HashSet = mcids(&element.dict).into_iter().collect(); + if ids.is_empty() { + return Err(ArtifactError::NoMarkedContent); + } + let pages = pdfcraft_model::pages(doc); + let page_ref = element.page.ok_or(ArtifactError::NoPage)?; + let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?; + let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; + let mut found = HashSet::new(); + for op in &mut ops { + let id = (op.op == b"BDC") + .then(|| match op.operands.get(1) { + Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int), + _ => None, + }) + .flatten() + .filter(|id| ids.contains(id)); + if let Some(id) = id { + *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); + found.insert(id); + } + } + if found != ids { + return Err(ArtifactError::ContentNotFound); + } + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops)))); + doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?; + if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?; + } + let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; + remove_from_parent(doc, parent, obj)?; + // Keep the retired object valid if another object holds an out-of-tree reference to it. + doc.update_dict(obj, |d| { + d.remove(b"P"); + })?; + Ok(()) +} + +fn has_child_structure(doc: &Document, tag: &Dict) -> bool { + fn child(doc: &Document, item: &Object) -> bool { + match item { + Object::Array(items) => items.iter().any(|item| child(doc, item)), + Object::Ref(r) => doc.get(*r).as_dict().is_some_and(|d| d.contains(b"S")), + Object::Dict(d) => d.contains(b"S"), + _ => false, + } + } + tag.get(b"K").is_some_and(|items| child(doc, items)) +} + +fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> { + let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?; + let replacement = match kids { + Object::Array(items) => { + if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 { + return Err(ArtifactError::AmbiguousParent); + } + Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()) + } + Object::Ref(r) if r == obj => Object::Array(Vec::new()), + _ => return Err(ArtifactError::NoParent), + }; + doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; + Ok(()) +} + /// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree. fn clear_parent_tree(doc: &mut Document, key: i64, obj: ObjRef) -> Result<(), AltError> { let Some(pt) = tree_root(doc).and_then(|r| r.get(b"ParentTree").and_then(Object::as_ref)) else { return Ok(()) }; diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 81fbade91d..cc3edd60f1 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -20,7 +20,7 @@ mod report; mod structure; mod tags; -pub use alt::{AltError, Figure, figures, mark_decorative, set_alt}; +pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use headings::{Heading, headings}; pub use report::{REPORT_WORDS, report_html}; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index e406569a8c..6f9deaeb80 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -591,6 +591,22 @@ fn setting_alt_text_and_marking_decorative() { assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}"); } +#[test] +fn marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity() { + let mut doc = figures_doc(); + let page = pdfcraft_model::pages(&doc)[0].obj; + mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)).unwrap(); + assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::>(), [6, 8]); + let c = doc.resolve(doc.get(page).as_dict().unwrap().get(b"Contents").unwrap()); + let pdfcraft_cos::Object::Stream(c) = &*c else { panic!("one content stream") }; + let text = String::from_utf8(c.decoded().unwrap()).unwrap(); + assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}"); + let report = check(&doc, &Options { rules: [Rule::TaggedContent, Rule::FiguresAltText].into(), pages: None }); + assert_eq!(failed(&report), Vec::::new(), "{:?}", report.results); + assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(6, 0)), Err(ArtifactError::HasChildren)); + assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)), Err(ArtifactError::NotATag(7))); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 71e96131e2..6a0e6a195b 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -476,6 +476,18 @@ impl Automation { self.accessibility_tags(a) } + pub(crate) fn accessibility_mark_tag_artifact(&mut self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + if object == 0 { + return Err(ToolError::InvalidArgs("object must be a positive tag object number".into())); + } + let generation = u16::try_from(a.int("generation")?) + .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?; + self.accessibility_tags(a) + } + pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result { let reference = |number: &str, generation: &str| -> Result { let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4a34909deb..247da4a55f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -755,6 +755,7 @@ impl Automation { "accessibility_set_alt" => self.accessibility_set_alt(&a)?, "accessibility_tags" => self.accessibility_tags(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..1ba549cf0d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -953,6 +953,11 @@ pub fn tools() -> Vec { "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), + t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + }), &["doc", "object", "generation"])), t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e788d96106..d44c7ab5b4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2047,6 +2047,40 @@ trailer << /Root 1 0 R >> assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_)))); } +#[test] +fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() { + let dir = workdir("a11y-artifact"); + std::fs::write( + dir.join("tagged.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj +4 0 obj << /Length 48 >> stream +/Figure << /MCID 0 >> BDC 10 150 40 20 re f EMC +endstream endobj +5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 8 0 R >> endobj +6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj +7 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >> endobj +8 0 obj << /Nums [0 [7 0 R]] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc }))["count"], 2); + let tagged = ok(&mut a, "accessibility_mark_tag_artifact", json!({ "doc": doc, "object": 7, "generation": 0 })); + assert_eq!(tagged["count"], 1); + assert_eq!(ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-content"] }))["failed"], 0); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "artifact.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("artifact.pdf")).unwrap())).unwrap(); + assert_eq!(pdfcraft_engine::a11y::tags(&saved).len(), 1); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Mark content as artifact"); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc }))["count"], 2); + assert!(matches!(a.call("accessibility_mark_tag_artifact", &json!({ "doc": doc, "object": 6, "generation": 0 })), Err(ToolError::Failed(_)))); +} + #[test] fn accessibility_review_record_compares_two_versions_without_changing_them() { let dir = workdir("a11y-review-record"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 651cd21af9..19f18fea95 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1359,6 +1359,10 @@ pub enum Edit { MarkDecorative { figure: u32, }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level /// script `name`. SetDocumentScript { @@ -1823,6 +1827,7 @@ impl Edit { Edit::SetTagProperty { .. } => Label::of("Change tag properties"), Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), Edit::XfaEvent { .. } => Label::of("Run form script"), @@ -2012,6 +2017,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetTagProperty { .. } | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } | Edit::AddOcrText { .. } | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } @@ -2351,6 +2357,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index a8c7d53826..ca8fe40e11 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2375,3 +2375,6 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Mark as artifact وضع علامة كعنصر زخرفي + Remove this leaf tag from reading order and mark its direct content as an artifact أزل هذه العلامة من ترتيب القراءة وضع علامة على محتواها المباشر كعنصر زخرفي + Mark content as artifact وضع علامة على المحتوى كعنصر زخرفي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e107408e2c..706bfe2ca5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2317,3 +2317,6 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Mark as artifact Маркиране като артефакт + Remove this leaf tag from reading order and mark its direct content as an artifact Премахнете този листов етикет от реда на четене и маркирайте прякото му съдържание като артефакт + Mark content as artifact Маркиране на съдържание като артефакт diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 835388f40f..547dea2591 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2428,3 +2428,6 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + Mark as artifact Označit jako artefakt + Remove this leaf tag from reading order and mark its direct content as an artifact Odeberte tuto značku listu z pořadí čtení a označte její přímý obsah jako artefakt + Mark content as artifact Označit obsah jako artefakt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7a418b54e0..289a6ee939 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2355,3 +2355,6 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + Mark as artifact Als Artefakt markieren + Remove this leaf tag from reading order and mark its direct content as an artifact Dieses Blatt-Tag aus der Lesereihenfolge entfernen und seinen direkten Inhalt als Artefakt markieren + Mark content as artifact Inhalt als Artefakt markieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3a99376d64..341637c85d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2364,3 +2364,6 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Mark as artifact Σήμανση ως τεχνούργημα + Remove this leaf tag from reading order and mark its direct content as an artifact Αφαιρέστε αυτήν την ετικέτα φύλλου από τη σειρά ανάγνωσης και σημειώστε το άμεσο περιεχόμενό της ως τεχνούργημα + Mark content as artifact Σήμανση περιεχομένου ως τεχνούργημα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f69380564a..b904e8034b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2484,3 +2484,6 @@ animation effects Off Desactivados This document is trying to open a local file: Este documento intenta abrir un archivo local: Move tag earlier Mover etiqueta antes Move tag later Mover etiqueta después + Mark as artifact Marcar como artefacto + Remove this leaf tag from reading order and mark its direct content as an artifact Quite esta etiqueta hoja del orden de lectura y marque su contenido directo como artefacto + Mark content as artifact Marcar contenido como artefacto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 76be27ca76..6d1140e4a9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2354,3 +2354,6 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + Mark as artifact Marquer comme artefact + Remove this leaf tag from reading order and mark its direct content as an artifact Retirez cette balise feuille de l’ordre de lecture et marquez son contenu direct comme artefact + Mark content as artifact Marquer le contenu comme artefact diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 221ad21f9b..38744f2e0c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2353,3 +2353,6 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + Mark as artifact Megjelölés műtárgyként + Remove this leaf tag from reading order and mark its direct content as an artifact Távolítsa el ezt a levélcímkét az olvasási sorrendből, és jelölje meg a közvetlen tartalmát műtárgyként + Mark content as artifact Tartalom megjelölése műtárgyként diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 94eeab1574..01beadf966 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2363,3 +2363,6 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + Mark as artifact Segna come artefatto + Remove this leaf tag from reading order and mark its direct content as an artifact Rimuovi questo tag foglia dall'ordine di lettura e segna il suo contenuto diretto come artefatto + Mark content as artifact Segna il contenuto come artefatto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 777410e908..feb2bc8219 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2354,3 +2354,6 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Mark as artifact アーティファクトとしてマーク + Remove this leaf tag from reading order and mark its direct content as an artifact このリーフタグを読み上げ順序から削除し、その直接のコンテンツをアーティファクトとしてマークします + Mark content as artifact コンテンツをアーティファクトとしてマーク diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 2ce5830afd..09187a4609 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2352,3 +2352,6 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Mark as artifact Артефакт ретінде белгілеу + Remove this leaf tag from reading order and mark its direct content as an artifact Осы жапырақ тегін оқу ретінен алып тастап, оның тікелей мазмұнын артефакт ретінде белгілеңіз + Mark content as artifact Мазмұнды артефакт ретінде белгілеу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 36c698c819..2f723a4524 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2598,3 +2598,6 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + Mark as artifact Pažymėti kaip artefaktą + Remove this leaf tag from reading order and mark its direct content as an artifact Pašalinkite šią lapo žymą iš skaitymo tvarkos ir pažymėkite jos tiesioginį turinį kaip artefaktą + Mark content as artifact Pažymėti turinį kaip artefaktą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b443c84157..7dd7c0dba6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2334,3 +2334,6 @@ animation effects Off Wyłączone This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: Move tag earlier Przenieś znacznik wcześniej Move tag later Przenieś znacznik później + Mark as artifact Oznacz jako artefakt + Remove this leaf tag from reading order and mark its direct content as an artifact Usuń ten znacznik liścia z kolejności odczytu i oznacz jego bezpośrednią zawartość jako artefakt + Mark content as artifact Oznacz zawartość jako artefakt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9b2a683948..261a86810e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2368,3 +2368,6 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + Mark as artifact Marcar como artefato + Remove this leaf tag from reading order and mark its direct content as an artifact Remova esta tag folha da ordem de leitura e marque seu conteúdo direto como artefato + Mark content as artifact Marcar conteúdo como artefato diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 834e552824..890e72e9a7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2267,3 +2267,6 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Mark as artifact Пометить как артефакт + Remove this leaf tag from reading order and mark its direct content as an artifact Удалите этот конечный тег из порядка чтения и пометьте его непосредственное содержимое как артефакт + Mark content as artifact Пометить содержимое как артефакт diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 506e52ee42..adafdca009 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2359,3 +2359,6 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + Mark as artifact Označi kot artefakt + Remove this leaf tag from reading order and mark its direct content as an artifact Odstranite to listno oznako iz vrstnega reda branja in označite njeno neposredno vsebino kot artefakt + Mark content as artifact Označi vsebino kot artefakt diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 08c53e84f2..6a56688acd 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2267,3 +2267,6 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Mark as artifact ఆర్టిఫాక్ట్‌గా గుర్తించు + Remove this leaf tag from reading order and mark its direct content as an artifact ఈ లీఫ్ ట్యాగ్‌ను పఠన క్రమం నుంచి తీసివేసి, దాని ప్రత్యక్ష కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించండి + Mark content as artifact కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index eb03e7f99b..af37483b4a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2350,3 +2350,6 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + Mark as artifact Yapıt olarak işaretle + Remove this leaf tag from reading order and mark its direct content as an artifact Bu yaprak etiketini okuma sırasından kaldırın ve doğrudan içeriğini yapıt olarak işaretleyin + Mark content as artifact İçeriği yapıt olarak işaretle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index eb1cea0e16..c0c4e83d1d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2376,3 +2376,6 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Mark as artifact Позначити як артефакт + Remove this leaf tag from reading order and mark its direct content as an artifact Вилучіть цей кінцевий тег із порядку читання та позначте його безпосередній вміст як артефакт + Mark content as artifact Позначити вміст як артефакт diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d13078e7a8..f914db633d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2318,3 +2318,6 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Mark as artifact 标记为工件 + Remove this leaf tag from reading order and mark its direct content as an artifact 从阅读顺序中移除此叶标签,并将其直接内容标记为工件 + Mark content as artifact 将内容标记为工件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9f8856ae14..7e49a5e69c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2228,3 +2228,6 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Mark as artifact 標記為成品 + Remove this leaf tag from reading order and mark its direct content as an artifact 從閱讀順序中移除此葉標記,並將其直接內容標記為成品 + Mark content as artifact 將內容標記為成品 diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs index 2fe917f7c3..b72a6520ba 100644 --- a/crates/ui-egui/src/tags_ui.rs +++ b/crates/ui-egui/src/tags_ui.rs @@ -191,10 +191,14 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state state.draft = Draft::from_tag(tag); } }); - let (mut earlier, mut later) = (false, false); + let (mut earlier, mut later, mut artifact) = (false, false, false); ui.horizontal(|ui| { earlier = ui.add_enabled(can_edit && !dirty && before.is_some(), egui::Button::new(tl!("Move tag earlier"))).clicked(); later = ui.add_enabled(can_edit && !dirty && after.is_some(), egui::Button::new(tl!("Move tag later"))).clicked(); + artifact = ui + .add_enabled(can_edit && !dirty, egui::Button::new(tl!("Mark as artifact"))) + .on_hover_text(tl!("Remove this leaf tag from reading order and mark its direct content as an artifact")) + .clicked(); }); ( page, @@ -204,6 +208,8 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state before.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::Before }) } else if later { after.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::After }) + } else if artifact { + Some(Edit::MarkTagArtifact { object: tag.object }) } else { None }, diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index 6872ab470c..e90b2dcf28 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -337,6 +337,27 @@ fn tags_panel_moves_a_tag_within_its_siblings() { assert_eq!(doc.can_undo(), Some("Move object")); } +#[test] +fn tags_panel_marks_a_leaf_tag_as_an_artifact() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("a11y.tags")); + h.run_steps(2); + h.get_by_label("Figure · 7").click(); + h.run_steps(1); + h.get_by_label("Mark as artifact").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.tags().into_iter().map(|tag| tag.object.num).collect::>(), [6, 8]); + assert_eq!(doc.can_undo(), Some("Mark content as artifact")); +} + #[test] fn checker_finding_opens_its_structure_tag() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 93f62a4cac..22ee2c66d6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8945,11 +8945,11 @@ title = "Mark content as artifact" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_mark_tag_artifact"] +evidence = ["crates/a11y/src/tests.rs::marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity", "crates/automation/tests/automation.rs::marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved", "crates/ui-egui/tests/accessibility.rs::tags_panel_marks_a_leaf_tag_as_an_artifact"] +notes = "Tags panel and automation can retire a reachable indirect leaf tag that owns direct MCIDs: its visible page content remains but becomes /Artifact and the parent-tree references are cleared. Tags with structural children, non-MCID content, missing pages or ambiguous parents are refused. Arbitrary painted-content selection, direct structure dictionaries and removing whole tagged subtrees remain unsupported." [[feature]] id = "a11y.find-untagged" From e770c564a888128ac5338ca8a5c463bd80fc3979 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:18:23 +0900 Subject: [PATCH 018/207] COS: resolve xref entries on demand (#1505) Co-authored-by: Claude Haiku 5.5 --- crates/cos/src/document.rs | 700 +++++++++++++++++++++++++++++++++++-- 1 file changed, 665 insertions(+), 35 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index f58c12cb5f..1f197218ab 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -61,6 +61,236 @@ pub enum XrefEntry { InStream { stream: u32, index: u32 }, } +#[derive(Clone, Copy, Debug)] +struct XrefRange { + start: u32, + count: u32, + offset: usize, + stride: usize, +} + +#[derive(Clone, Debug)] +enum XrefSectionData { + Table, + Stream { bytes: Arc>, widths: [usize; 3] }, +} + +#[derive(Clone, Debug)] +struct XrefSection { + data: XrefSectionData, + ranges: Vec, + row_count: usize, + max_num: u32, + has_entries: bool, +} + +#[derive(Clone, Debug)] +struct XrefRevisionSection { + primary: XrefSection, + hybrid: Option, +} + +#[derive(Clone, Debug)] +enum XrefIndex { + Compact { revisions: Vec, max_num: u32, has_entries: bool }, + Materialized(Arc>), +} + +const MAX_COMPACT_XREF_ROWS: usize = 2_000_000; +const MAX_COMPACT_XREF_BYTES: usize = 64 * 1024 * 1024; + +impl XrefSection { + fn has_any_entry(&self) -> bool { + match &self.data { + XrefSectionData::Table => self.has_entries, + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + if row_width == 0 { + return false; + } + for range in &self.ranges { + let available = bytes.len().saturating_sub(range.offset) / row_width; + for i in 0..available.min(range.count as usize) { + let Some(offset) = i.checked_mul(row_width).and_then(|delta| range.offset.checked_add(delta)) else { continue }; + let Some(row) = bytes.get(offset..offset.saturating_add(row_width)) else { continue }; + let read_field = |from: usize, width: usize, default: u64| -> Option { + if width == 0 { + return Some(default); + } + let end = from.checked_add(width)?; + Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) + }; + let Some(kind) = read_field(0, widths[0], 1) else { continue }; + if kind == 0 || kind == 2 { + return true; + } + if kind == 1 && read_field(widths[0], widths[1], 0).is_some_and(|offset| offset != 0) { + return true; + } + } + } + false + } + } + } + + fn retained_bytes(&self) -> usize { + match &self.data { + XrefSectionData::Table => 0, + XrefSectionData::Stream { bytes, .. } => bytes.capacity(), + } + } + + fn lookup(&self, num: u32, file: &[u8]) -> Option { + let after = self.ranges.partition_point(|range| range.start <= num); + let range = self.ranges.get(after.checked_sub(1)?)?; + let end = u64::from(range.start).checked_add(u64::from(range.count))?; + if u64::from(num) >= end { + return None; + } + let row = usize::try_from(num.checked_sub(range.start)?).ok()?; + let offset = range.offset.checked_add(row.checked_mul(range.stride)?)?; + match &self.data { + XrefSectionData::Table => { + let mut lexer = Lexer::new(file, offset); + lexer.skip_ws(); + let object_offset = lexer.token(); + lexer.skip_ws(); + let generation = lexer.token(); + lexer.skip_ws(); + let kind = lexer.token(); + match kind { + b"n" => { + let object_offset = std::str::from_utf8(object_offset).ok()?.parse::().ok()?; + if object_offset == 0 { + return None; + } + let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; + Some(XrefEntry::InFile { offset: object_offset, generation }) + } + b"f" => { + let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; + Some(XrefEntry::Free { next_generation: generation }) + } + _ => None, + } + } + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + let end = offset.checked_add(row_width)?; + let row = bytes.get(offset..end)?; + let field = |from: usize, width: usize, default: u64| -> Option { + if width == 0 { + return Some(default); + } + let end = from.checked_add(width)?; + Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) + }; + let kind = field(0, widths[0], 1)?; + let a = field(widths[0], widths[1], 0)?; + let b = field(widths[0] + widths[1], widths[2], 0)?; + let entry = match kind { + 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, + 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, + 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, + _ => return None, + }; + (!matches!(entry, XrefEntry::InFile { offset: 0, .. })).then_some(entry) + } + } + } + + fn extend_numbers(&self, numbers: &mut Vec) { + for range in &self.ranges { + let available = match &self.data { + XrefSectionData::Table => range.count as usize, + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0) + } + }; + for i in 0..available.min(range.count as usize) { + if let Ok(i) = u32::try_from(i) + && let Some(num) = range.start.checked_add(i) + { + numbers.push(num); + } + } + } + } +} + +impl XrefRevisionSection { + fn lookup(&self, num: u32, file: &[u8]) -> Option { + let primary = self.primary.lookup(num, file); + let hybrid = self.hybrid.as_ref().and_then(|section| section.lookup(num, file)); + match primary { + None | Some(XrefEntry::Free { .. }) => hybrid.or(primary), + entry => entry, + } + } + + fn extend_numbers(&self, numbers: &mut Vec) { + self.primary.extend_numbers(numbers); + if let Some(hybrid) = &self.hybrid { + hybrid.extend_numbers(numbers); + } + } +} + +impl XrefIndex { + fn empty() -> Self { + Self::Materialized(Arc::new(BTreeMap::new())) + } + + fn lookup(&self, num: u32, file: &[u8]) -> Option { + match self { + Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)), + Self::Materialized(entries) => entries.get(&num).copied(), + } + } + + fn is_empty(&self) -> bool { + match self { + Self::Compact { has_entries, .. } => !has_entries, + Self::Materialized(entries) => entries.is_empty(), + } + } + + fn max_num(&self) -> u32 { + match self { + Self::Compact { max_num, .. } => *max_num, + Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0), + } + } + + fn object_numbers(&self, file: &[u8]) -> Vec { + match self { + Self::Materialized(entries) => { + entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect() + } + Self::Compact { revisions, .. } => { + if let [revision] = revisions.as_slice() + && revision.hybrid.is_none() + { + let mut numbers = Vec::with_capacity(revision.primary.row_count); + revision.primary.extend_numbers(&mut numbers); + numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); + return numbers; + } + let mut numbers = Vec::new(); + for revision in revisions { + revision.extend_numbers(&mut numbers); + } + numbers.sort_unstable(); + numbers.dedup(); + numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); + numbers + } + } + } +} + /// One cross-reference section of the file (one per save; the first is the original). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Revision { @@ -102,7 +332,7 @@ impl Eq for SourceIdentity {} pub struct Document { data: Arc>, source_identity: Arc<()>, - entries: Arc>, + entries: Arc, trailer: Dict, revisions: Arc>, /// See [`Self::xref_oddities`]. @@ -207,7 +437,7 @@ fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { impl std::fmt::Debug for Document { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Document").field("bytes", &self.data.len()).field("objects", &self.entries.len()).field("edits", &self.overlay.len()).finish() + f.debug_struct("Document").field("bytes", &self.data.len()).field("xref", &self.entries).field("edits", &self.overlay.len()).finish() } } @@ -218,7 +448,7 @@ impl Document { let mut doc = Document { data: Arc::new(Vec::new()), source_identity: Arc::new(()), - entries: Arc::new(BTreeMap::new()), + entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), xref_oddities: Arc::default(), @@ -282,7 +512,7 @@ impl Document { let mut doc = Document { data, source_identity: Arc::new(()), - entries: Arc::new(BTreeMap::new()), + entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), xref_oddities: Arc::default(), @@ -339,7 +569,7 @@ impl Document { { doc.authenticate(&enc, password)?; } - let max = doc.entries.keys().next_back().copied().unwrap_or(0); + let max = doc.entries.max_num(); let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32; doc.next_num = max.max(size.saturating_sub(1)) + 1; Ok(doc) @@ -589,7 +819,7 @@ impl Document { /// Where object `num` is stored in the file (ignoring unsaved edits). pub fn xref_entry(&self, num: u32) -> Option { - self.entries.get(&num).cloned() + self.entries.lookup(num, &self.data) } /// Cross-reference entries this reader did not take at face value, as (byte offset of the @@ -609,15 +839,15 @@ impl Document { /// position of its object stream. When it does not, [`Self::get`] still finds the object by /// searching, but another reader may resolve the number differently. Ignores unsaved edits. pub fn xref_entry_is_exact(&self, num: u32) -> bool { - match self.entries.get(&num) { + match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, Some(XrefEntry::InFile { offset, .. }) => { let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); // Offsets may count from a header that does not start the file. - usize::try_from(*offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) + usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } Some(XrefEntry::InStream { stream, index }) => { - self.objstm(*stream).is_ok_and(|stm| usize::try_from(*index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) + self.objstm(stream).is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) } } } @@ -798,7 +1028,7 @@ impl Document { if depth > 16 { return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() }); } - match self.entries.get(&num) { + match self.entries.lookup(num, &self.data) { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { let resolve = |r: ObjRef| { @@ -809,7 +1039,7 @@ impl Document { LENGTHS.with(|m| m.borrow_mut().insert(r.num, length)); length }; - let off = *offset as usize; + let off = offset as usize; let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, @@ -821,10 +1051,10 @@ impl Document { .ok_or(CosError::MissingObject(num)) } Some(XrefEntry::InStream { stream, index }) => { - let stm = self.objstm(*stream)?; + let stm = self.objstm(stream)?; let (n, off) = stm .index - .get(*index as usize) + .get(index as usize) .copied() .or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied()) .ok_or(CosError::MissingObject(num))?; @@ -971,9 +1201,9 @@ impl Document { match self.overlay.get(&num) { Some(Slot::Set(g, _)) => *g, Some(Slot::Freed(g)) => *g, - None => match self.entries.get(&num) { - Some(XrefEntry::InFile { generation, .. }) => *generation, - Some(XrefEntry::Free { next_generation }) => *next_generation, + None => match self.entries.lookup(num, &self.data) { + Some(XrefEntry::InFile { generation, .. }) => generation, + Some(XrefEntry::Free { next_generation }) => next_generation, _ => 0, }, } @@ -992,26 +1222,23 @@ impl Document { /// All object numbers known (file and overlay), excluding freed ones. pub fn object_numbers(&self) -> Vec { - // Both maps are ordered. Overlay slots replace source slots, including frees; - // merge them directly without a second set and a sort of every object number. - let mut numbers = Vec::with_capacity(self.entries.len().max(self.overlay.len())); - let mut source = self.entries.iter().peekable(); + let source_numbers = self.entries.object_numbers(&self.data); + let mut numbers = Vec::with_capacity(source_numbers.len().max(self.overlay.len())); + let mut source = source_numbers.into_iter().peekable(); for (&num, slot) in &self.overlay { - while source.peek().is_some_and(|(n, _)| **n < num) { - if let Some((&n, entry)) = source.next() - && !matches!(entry, XrefEntry::Free { .. }) - { + while source.peek().is_some_and(|n| *n < num) { + if let Some(n) = source.next() { numbers.push(n); } } - if source.peek().is_some_and(|(n, _)| **n == num) { + if source.peek().is_some_and(|n| *n == num) { source.next(); } if matches!(slot, Slot::Set(..)) { numbers.push(num); } } - numbers.extend(source.filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num))); + numbers.extend(source); numbers } @@ -1029,7 +1256,313 @@ impl Document { } #[allow(clippy::type_complexity)] - fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { + fn read_xref_chain(&self, log: &mut Vec) -> Result<(XrefIndex, Dict, Vec, Vec<(u64, u32)>), CosError> { + let mut compact_log = Vec::new(); + if let Ok(Some((index, trailer, revisions))) = self.read_compact_xref_chain(&mut compact_log) { + let size = trailer.int(b"Size").unwrap_or(0).max(0); + if size > 0 && size <= i64::from(u32::MAX) && u64::from(index.max_num()) < size as u64 { + log.extend(compact_log); + // The compact reader declines any section with set-aside entries, so there are none here. + return Ok((index, trailer, revisions, Vec::new())); + } + } + let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?; + Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities)) + } + + fn read_compact_xref_chain(&self, log: &mut Vec) -> Result)>, CosError> { + let data = &self.data; + let tail_start = data.len().saturating_sub(4096); + let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; + let mut lx = Lexer::new(data, sx + 9); + let first = match lx.object() { + Ok(Object::Int(n)) if n >= 0 => n as u64, + _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }), + }; + let mut sections = Vec::new(); + let mut retained_rows = 0usize; + let mut retained_bytes = 0usize; + let mut trailer: Option = None; + let mut revisions = Vec::new(); + let mut visited = HashSet::new(); + let mut next = Some(first); + while let Some(off) = next.take() { + if !visited.insert(off) || visited.len() > 10_000 { + log.push(format!("cross-reference chain loops at offset {off}; stopped")); + break; + } + let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => v, + Ok(None) => return Ok(None), + Err(e) if self.header_offset > 0 => { + log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); + match self.read_compact_section(off as usize + self.header_offset)? { + Some(v) => v, + None => return Ok(None), + } + } + Err(e) => { + if sections.is_empty() { + return Err(e); + } + log.push(format!("older cross-reference section at {off} is unreadable: {e}")); + break; + } + }; + revisions.push(Revision { xref_offset: off, is_stream }); + let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; + let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { + return Ok(None); + }; + retained_rows = rows; + retained_bytes = bytes; + let hybrid = if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + match self.read_compact_section(x as usize) { + Ok(Some((section, _, _))) => { + let Some(rows) = retained_rows.checked_add(section.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { + return Ok(None); + }; + let Some(bytes) = retained_bytes.checked_add(section.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) + else { + return Ok(None); + }; + retained_rows = rows; + retained_bytes = bytes; + Some(section) + } + Ok(None) => return Ok(None), + Err(e) => { + log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); + None + } + } + } else { + None + }; + if let Some(hybrid) = &hybrid { + // A hybrid entry that disagrees with the table is set aside by the materialized reader. + let mut numbers = Vec::new(); + hybrid.extend_numbers(&mut numbers); + for num in numbers { + let Some(entry) = hybrid.lookup(num, &self.data) else { continue }; + if primary.lookup(num, &self.data).is_some_and(|old| !matches!(old, XrefEntry::Free { .. }) && old != entry) { + return Ok(None); + } + } + } + sections.push(XrefRevisionSection { primary, hybrid }); + next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); + if trailer.is_none() { + trailer = Some(sect_trailer); + } + } + revisions.reverse(); + let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?; + for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { + trailer.remove(k); + } + let mut max_num = 0; + let mut has_entries = false; + for revision in §ions { + max_num = max_num.max(revision.primary.max_num); + has_entries |= revision.primary.has_entries; + if let Some(hybrid) = &revision.hybrid { + max_num = max_num.max(hybrid.max_num); + has_entries |= hybrid.has_entries; + } + } + Ok(Some((XrefIndex::Compact { revisions: sections, max_num, has_entries }, trailer, revisions))) + } + + fn read_compact_section(&self, off: usize) -> Result, CosError> { + let data = &self.data; + let mut lx = Lexer::new(data, off); + if lx.eat_keyword(b"xref") { + let mut ranges = Vec::new(); + let mut layout_ok = true; + let mut max_num = 0; + let mut has_entries = false; + let mut previous_end = None; + let mut row_count = 0usize; + loop { + lx.skip_ws(); + if lx.eat_keyword(b"trailer") { + let t = lx.object()?; + let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?; + if !layout_ok { + return Ok(None); + } + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + } + let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { + return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); + }; + if !valid_xref_range(start, count) || count > 10_000_000 { + return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); + } + let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; + if next_rows > MAX_COMPACT_XREF_ROWS { + return Ok(None); + } + row_count = next_rows; + let mut range_offset = None; + let mut stride = None; + let mut previous_record = None; + for i in 0..count { + lx.skip_ws(); + let record = lx.pos; + if range_offset.is_none() { + range_offset = Some(record); + } + if let Some(previous) = previous_record { + let Some(delta) = record.checked_sub(previous) else { + layout_ok = false; + continue; + }; + if stride.is_some_and(|known| known != delta) { + layout_ok = false; + } + stride.get_or_insert(delta); + } + previous_record = Some(record); + let o = lx.token(); + lx.skip_ws(); + let _generation = lx.token(); + lx.skip_ws(); + let kind = lx.token(); + let num = (start + i) as u32; + let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok()); + match (kind, offset) { + (b"n", Some(offset)) if offset != 0 => { + has_entries = true; + max_num = max_num.max(num); + } + (b"n", Some(_)) => { + // Set aside by the materialized reader, which records it. + if num != 0 { + return Ok(None); + } + } + (b"f", _) => { + has_entries = true; + max_num = max_num.max(num); + } + _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }), + } + } + if count > 0 { + let range_start = start as u32; + let range_count = count as u32; + let end = u64::from(range_start) + u64::from(range_count); + if previous_end.is_some_and(|previous| u64::from(range_start) < previous) { + layout_ok = false; + } + previous_end = Some(end); + ranges.push(XrefRange { + start: range_start, + count: range_count, + offset: range_offset.unwrap_or(lx.pos), + stride: stride.unwrap_or(1), + }); + } + } + } + let (_, obj) = parse_indirect(data, off, &|_| None)?; + let Object::Stream(stream) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) }; + if stream.dict.name(b"Type") != Some(b"XRef") { + return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() }); + } + let w: Vec = stream + .dict + .get(b"W") + .and_then(Object::as_array) + .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect()) + .unwrap_or_default(); + if w.len() < 3 { + return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() }); + } + let widths = [w[0], w[1], w[2]]; + let row = widths.iter().sum::(); + if row == 0 { + return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() }); + } + let size = stream.dict.int(b"Size").unwrap_or(0).max(0); + let index: Vec = match stream.dict.get(b"Index").and_then(Object::as_array) { + Some(a) => a.iter().filter_map(Object::as_int).collect(), + None => vec![0, size], + }; + let mut requested_rows = 0usize; + let mut previous_end = None; + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + if !valid_xref_range(*start, *count) { + return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() }); + } + let Ok(count) = usize::try_from(*count) else { return Ok(None) }; + let Some(next) = requested_rows.checked_add(count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; + if count > 0 { + let end = (*start as u64).saturating_add(count as u64); + if previous_end.is_some_and(|previous| (*start as u64) < previous) { + return Ok(None); + } + previous_end = Some(end); + } + requested_rows = next; + } + if requested_rows.checked_mul(row).is_none_or(|bytes| bytes > MAX_COMPACT_XREF_BYTES) { + return Ok(None); + } + let raw = stream.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit).min(MAX_COMPACT_XREF_BYTES))?; + if raw.capacity() > MAX_COMPACT_XREF_BYTES { + return Ok(None); + } + let mut ranges = Vec::new(); + let mut row_start = 0usize; + let available_rows = raw.len() / row; + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + let range_start = *start as u32; + let Ok(requested) = usize::try_from(*count) else { return Ok(None) }; + let range_count = requested.min(available_rows.saturating_sub(row_start)); + if range_count > 0 { + let Ok(range_count_u32) = u32::try_from(range_count) else { return Ok(None) }; + let Some(byte_offset) = row_start.checked_mul(row) else { return Ok(None) }; + ranges.push(XrefRange { start: range_start, count: range_count_u32, offset: byte_offset, stride: row }); + } + let Some(next) = row_start.checked_add(range_count) else { return Ok(None) }; + row_start = next; + } + // Rows the materialized reader sets aside (reserved types, offset 0) are left to it. + for range in &ranges { + for i in 0..range.count as usize { + let at = range.offset + i * row; + let Some(r) = raw.get(at..at + row) else { break }; + let field = |from: usize, width: usize, default: u64| -> u64 { + if width == 0 { + return default; + } + r[from..from + width].iter().fold(0u64, |acc, byte| acc << 8 | u64::from(*byte)) + }; + let kind = field(0, widths[0], 1); + let offset = field(widths[0], widths[1], 0); + if kind > 2 || (kind == 1 && offset == 0 && range.start + i as u32 != 0) { + return Ok(None); + } + } + } + let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); + let row_count = row_start; + let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false }; + let has_entries = section.has_any_entry(); + let section = XrefSection { has_entries, ..section }; + Ok(Some((section, stream.dict.clone(), true))) + } + + #[allow(clippy::type_complexity)] + fn read_xref_chain_materialized( + &self, + log: &mut Vec, + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; @@ -1255,7 +1788,7 @@ impl Document { if entries.is_empty() { return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() }); } - self.entries = Arc::new(entries.clone()); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()))); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Objects inside object streams. for num in objstms { @@ -1269,7 +1802,8 @@ impl Document { } } } - self.entries = Arc::new(entries); + let object_count = entries.len(); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself. let mut trailer = None; @@ -1288,8 +1822,8 @@ impl Document { None => { let catalog = self .entries - .keys() - .copied() + .object_numbers(&self.data) + .into_iter() .find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog"))); let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) }; let mut t = Dict::new(); @@ -1303,7 +1837,7 @@ impl Document { } self.trailer = trailer; self.revisions = Arc::new(Vec::new()); - log.push(format!("rebuilt cross-reference index from {} object headers", self.entries.len())); + log.push(format!("rebuilt cross-reference index from {object_count} object headers")); Ok(()) } } @@ -1367,6 +1901,101 @@ mod tests { out } + fn assert_compact_matches_materialized(doc: &Document) { + assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map"); + let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + assert_eq!(revisions.as_slice(), doc.revisions()); + let max = entries.keys().next_back().copied().unwrap_or(0); + assert_eq!(doc.entries.max_num(), max); + for num in 0..=max { + assert_eq!(doc.xref_entry(num), entries.get(&num).copied(), "object {num}"); + } + let expected: Vec<_> = entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect(); + assert_eq!(doc.object_numbers(), expected); + } + + #[test] + fn compact_xref_tables_streams_and_revisions_match_the_materialized_reader() { + let classic = Document::open(Arc::new(build( + &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], + "/Root 1 0 R", + ))) + .unwrap(); + assert_compact_matches_materialized(&classic); + + let mut sparse = b"%PDF-1.7\n".to_vec(); + let root_offset = sparse.len(); + sparse.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); + let pages_offset = sparse.len(); + sparse.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let xref = sparse.len(); + sparse.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 1\n{root_offset:010} 00000 n \n5 1\n{pages_offset:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let sparse = Document::open(Arc::new(sparse)).unwrap(); + assert_eq!(sparse.object_numbers(), vec![1, 5]); + assert_compact_matches_materialized(&sparse); + + let mut sparse_stream = b"%PDF-1.7\n".to_vec(); + let mut offsets = [0u32; 8]; + offsets[1] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); + offsets[5] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + offsets[6] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"6 0 obj\n<< /Unused true >>\nendobj\n"); + offsets[7] = sparse_stream.len() as u32; + let xref_row = |kind: u8, offset: u32, generation: u16| { + let mut row = Vec::with_capacity(7); + row.push(kind); + row.extend_from_slice(&offset.to_be_bytes()); + row.extend_from_slice(&generation.to_be_bytes()); + row + }; + let mut rows = Vec::new(); + rows.extend(xref_row(0, 0, u16::MAX)); + rows.extend(xref_row(1, offsets[1], 0)); + rows.extend(xref_row(1, offsets[5], 0)); + rows.extend(xref_row(1, offsets[6], 0)); + rows.extend(xref_row(1, offsets[7], 0)); + sparse_stream.extend_from_slice( + format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Index [0 2 5 3] /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + sparse_stream.extend_from_slice(&rows); + sparse_stream.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[7]).as_bytes()); + let sparse_stream = Document::open(Arc::new(sparse_stream)).unwrap(); + assert_eq!(sparse_stream.object_numbers(), vec![1, 5, 6, 7]); + assert_compact_matches_materialized(&sparse_stream); + + let stream_bytes = crate::write_full(&classic, &crate::SaveOptions::default()).unwrap(); + let stream = Document::open(Arc::new(stream_bytes)).unwrap(); + assert!(stream.revisions().last().is_some_and(|revision| revision.is_stream)); + assert_compact_matches_materialized(&stream); + + let mut edited = classic.clone(); + edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let updated = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(updated.revisions().len(), 2); + assert_compact_matches_materialized(&updated); + } + + #[test] + fn malformed_size_uses_the_materialized_compatibility_path() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], "/Root 1 0 R"); + let marker = b"/Size 4"; + let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap(); + bytes[at..at + marker.len()].copy_from_slice(b"/Size 2"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_))); + assert_eq!(doc.next_num, 4); + assert_eq!(doc.object_numbers(), vec![1, 2, 3]); + } + /// Entries the reader sets aside are remembered, so a caller comparing revisions can tell /// that another reader may resolve the object differently. #[test] @@ -1482,7 +2111,7 @@ mod tests { fn object_numbers_merge_source_and_overlay_in_order() { let mut doc = Document::new_empty(); doc.overlay.clear(); - doc.entries = Arc::new(BTreeMap::from([ + doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([ (0, XrefEntry::Free { next_generation: 65535 }), (2, XrefEntry::InFile { offset: 10, generation: 7 }), (4, XrefEntry::Free { next_generation: 9 }), @@ -1490,7 +2119,7 @@ mod tests { (8, XrefEntry::InFile { offset: 20, generation: 5 }), (10, XrefEntry::InFile { offset: 30, generation: 3 }), (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }), - ])); + ])))); doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot @@ -1506,7 +2135,7 @@ mod tests { assert_eq!(doc.generation(10), 4); doc.overlay.clear(); assert_eq!(doc.object_numbers(), vec![2, 6, 8, 10, u32::MAX]); - doc.entries = Arc::default(); + doc.entries = Arc::new(XrefIndex::empty()); assert!(doc.object_numbers().is_empty()); doc.set(ObjRef::new(5, 7), Object::Int(5)); assert_eq!(doc.object_numbers(), vec![5]); @@ -1737,6 +2366,7 @@ mod tests { out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); let doc = Document::open(Arc::new(out)).unwrap(); assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert_compact_matches_materialized(&doc); let hidden = doc.get(ObjRef::new(3, 0)); assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden").and_then(|h| h.as_string().map(|s| s.to_text()))), Some("yes".into())); } From 5105eb732c1a2eb893fb6745ff551a025a627844 Mon Sep 17 00:00:00 2001 From: tylerstoltz Date: Thu, 15 Oct 2026 02:18:34 -0400 Subject: [PATCH 019/207] feat(ui): drop files on the Pages panel to insert pages; track the pointer during drags on Windows (#1547) Files dragged from outside onto the Pages panel are inserted at the gap under the pointer (with a drop indicator) instead of opening as new documents. Files dropped beside the panel still open as before. On Windows, winit sends no pointer moves while files are dragged over the window, so the cursor is read with GetPhysicalCursorPos (winsafe "user" feature). This also makes the page grid show and use the gap under the pointer during a drag on Windows. Linux/BSD still have no pointer during a drag and keep the existing fallback. Tests: two new kittest cases in tests/editing.rs (drop on the panel inserts at the gap / at the end; drop beside the panel opens a document). The Windows cursor path was checked by hand. --- crates/ui-egui/Cargo.toml | 5 +-- crates/ui-egui/src/canvas.rs | 9 ++++++ crates/ui-egui/src/drag_pointer.rs | 17 ++++++++++- crates/ui-egui/src/lib.rs | 49 +++++++++++++++++++++++------- crates/ui-egui/src/panels.rs | 22 ++++++++++++++ crates/ui-egui/tests/editing.rs | 42 +++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5400829cde..e45c596881 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -55,8 +55,9 @@ rustix = { version = "1", features = ["fs"] } [target.'cfg(windows)'.dependencies] # Auto interface language: the Windows display language, without starting a process. sys-locale = "0.3" -# Region ▸ Measurement system for the default Units, read from the registry without starting reg.exe. -winsafe = { version = "0.0.29", default-features = false, features = ["advapi"] } +# Region ▸ Measurement system for the default Units, read from the registry without starting reg.exe; +# where the pointer is while files are dragged over the window (winit doesn't say). +winsafe = { version = "0.0.29", default-features = false, features = ["advapi", "user"] } [target.'cfg(target_arch = "wasm32")'.dependencies] rfd = { workspace = true, features = ["file-handle-inner"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..3ae27c40ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -449,6 +449,11 @@ pub struct DocView { /// The page-grid gap under the pointer this frame (where dropped files go), when it can /// take pages. pub grid_gap: Option, + /// The visible part of the Pages panel as last drawn, when it can take dropped files + /// (cleared each frame before the panels draw). + pub pages_panel: Option, + /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. + pub panel_gap: Option, /// 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 @@ -649,6 +654,8 @@ impl DocView { pending_action: None, insert_at: None, grid_gap: None, + pages_panel: None, + panel_gap: None, grid_zoom: 1.0, grid_zoom_request: None, grid_anchor: None, @@ -1315,6 +1322,8 @@ impl DocView { /// Start collecting demand from every consumer before scheduling or uploading results. pub(crate) fn begin_render_frame(&mut self) { + self.pages_panel = None; + self.panel_gap = None; self.thumb_demand.clear(); self.frame_queue.clear(); self.frame_visible.clear(); diff --git a/crates/ui-egui/src/drag_pointer.rs b/crates/ui-egui/src/drag_pointer.rs index 898d319fe6..05ef05c426 100644 --- a/crates/ui-egui/src/drag_pointer.rs +++ b/crates/ui-egui/src/drag_pointer.rs @@ -19,7 +19,22 @@ pub(crate) fn in_window(ctx: &egui::Context) -> Option { (pos.x.is_finite() && pos.y.is_finite()).then_some(pos) } -#[cfg(not(target_os = "macos"))] +/// The pointer in this window's coordinates (egui points), when the system can tell. +#[cfg(windows)] +pub(crate) fn in_window(ctx: &egui::Context) -> Option { + // Physical screen pixels, whatever this thread's DPI awareness. + let pointer = winsafe::GetPhysicalCursorPos().ok()?; + let inner = ctx.input(|i| i.viewport().inner_rect)?; + // egui places the window in points: physical pixels over the pixels per point (zoom included). + let ppp = ctx.pixels_per_point(); + if !(ppp.is_finite() && ppp > 0.0) { + return None; + } + let pos = egui::pos2(pointer.x as f32 / ppp - inner.min.x, pointer.y as f32 / ppp - inner.min.y); + (pos.x.is_finite() && pos.y.is_finite()).then_some(pos) +} + +#[cfg(not(any(target_os = "macos", windows)))] pub(crate) fn in_window(_ctx: &egui::Context) -> Option { None } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a00..b01a1e9bf6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,9 +250,11 @@ fn read_grid_file((name, source): GridFile) -> Result<(String, Vec), (String Ok((name, bytes)) } -/// Files dropped on a document's page grid. +/// Files dropped on a document's page grid or its Pages panel. struct GridDrop { doc: pdfcraft_engine::DocId, + /// Dropped on the Pages panel (its gap decides where they go), not the page grid. + panel: bool, files: Vec, /// When to stop waiting for the pointer (egui time, seconds). deadline: f64, @@ -1368,11 +1370,29 @@ impl PdfCraftApp { (self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id) } - /// Files dropped while the page grid shows are inserted into it rather than opened: they - /// are kept until [`Self::finish_grid_drop`] knows the gap. Returns the files not taken. + /// The document whose Pages panel is under the pointer and may take pages, if any. + #[cfg(not(target_arch = "wasm32"))] + fn panel_target(&self, ctx: &egui::Context) -> Option { + let view = self.active.and_then(|i| self.views.get(i))?; + let pointer = ctx.input(|i| i.pointer.hover_pos())?; + let over = view.pages_panel.is_some_and(|panel| panel.contains(pointer)); + (over && self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id) + } + + /// Files dropped on the Pages panel, or while the page grid shows, are inserted into the + /// document rather than opened: they are kept until [`Self::finish_grid_drop`] knows the + /// gap. Returns the files not taken. #[cfg(not(target_arch = "wasm32"))] fn drop_on_grid(&mut self, dropped: Vec, ctx: &egui::Context) -> Vec { - let Some(id) = self.grid_target().filter(|_| !dropped.is_empty()) else { return dropped }; + if dropped.is_empty() { + return dropped; + } + // The panel first: the grid takes files dropped anywhere in the window. + let (id, panel) = match (self.panel_target(ctx), self.grid_target()) { + (Some(id), _) => (id, true), + (None, Some(id)) => (id, false), + (None, None) => return dropped, + }; let mut files = Vec::new(); for f in &dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); @@ -1389,22 +1409,29 @@ impl PdfCraftApp { // Where there is no telling where the pointer is during the drag, its place arrives // with the first move after the drop. ctx.request_repaint(); - self.grid_drop = Some(GridDrop { doc: id, files, deadline: ctx.input(|i| i.time) + 1.0 }); + self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: ctx.input(|i| i.time) + 1.0 }); } Vec::new() } - /// Insert files dropped on the page grid at the gap under the pointer, or at the end when - /// the pointer hasn't shown up in time. + /// Insert files dropped on the page grid or the Pages panel at the gap under the pointer, + /// or at the end when the pointer hasn't shown up in time. fn finish_grid_drop(&mut self, ctx: &egui::Context) { - let Some(GridDrop { doc: id, deadline, .. }) = &self.grid_drop else { return }; - let (id, deadline) = (*id, *deadline); - if self.grid_target() != Some(id) { + let Some(GridDrop { doc: id, panel, deadline, .. }) = &self.grid_drop else { return }; + let (id, panel, deadline) = (*id, *panel, *deadline); + let still = if panel { + self.dialog.is_none() + && self.active_ids().map(|(_, active)| active) == Some(id) + && self.session.get(id).is_some_and(|d| d.allows_assembly()) + } else { + self.grid_target() == Some(id) + }; + if !still { self.grid_drop = None; return self.notify_tr("The document changed while you were choosing a file, so nothing was changed."); } let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; - let gap = match view.grid_gap { + let gap = match if panel { view.panel_gap } else { view.grid_gap } { Some(gap) => gap, None if ctx.input(|i| i.time) >= deadline => usize::MAX, None => return ctx.request_repaint(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..cf71f99a68 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1114,6 +1114,28 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie ui.add_space(4.0); } page_drag(ui, t, view, &rows, dropped); + file_drop(ui, t, view, &rows, editable && !modal); +} + +/// Files dragged in from outside go to the gap under the pointer: remember where the panel is +/// and that gap, for the app to take the drop, and show where the pages would go. +fn file_drop(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, rows: &[Rect], editable: bool) { + if !editable { + return; + } + let clip = ui.clip_rect(); + view.pages_panel = Some(clip); + let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| clip.contains(*p)); + view.panel_gap = pointer.filter(|_| view.panel_drag.is_none()).and_then(|p| panel_gap(rows, p.y)); + if pointer.is_none() || ui.input(|i| i.raw.hovered_files.is_empty()) { + return; + } + let painter = ui.painter().with_clip_rect(clip); + painter.rect_stroke(clip.shrink(2.0), CornerRadius::same(6), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if let (Some(gap), Some(first), Some(last)) = (view.panel_gap, rows.first(), rows.last()) { + let y = rows.get(gap).map_or(last.bottom() + 2.0, |r| r.top() - 3.0); + painter.line_segment([pos2(first.left(), y), pos2(first.right(), y)], Stroke::new(3.0, t.accent)); + } } /// Queue deleting the selection (or the current page) of a document of `n` pages, unless that diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index c470837775..2708b6c3db 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1686,6 +1686,48 @@ fn files_dropped_outside_the_page_grid_still_open_as_documents() { assert_eq!(doc.bytes.as_slice(), fixture(1).as_slice()); } +/// The Pages panel's thumbnail of page `label` (the panel is right of the page view). +fn panel_thumb_rect(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).unwrap() +} + +#[test] +fn files_dropped_on_the_pages_panel_are_inserted_there() { + let mut h = harness(3, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // The top half of page 2's thumbnail: between pages 1 and 2. + let thumb = panel_thumb_rect(&h, "Page 2"); + h.hover_at(thumb.center() - egui::vec2(0.0, thumb.height() * 0.25)); + h.run_steps(2); + assert_eq!(h.state().views[0].panel_gap, Some(1)); + drop_files(&mut h, vec![Dropped("extra.pdf", fixture(2)), Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "dropped files are not opened as documents"); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "a note", "Page 2", "Page 3"]); + assert_eq!(picked(&h), [1, 2, 3], "the inserted pages are selected"); + // The bottom half of the last thumbnail: the end. + let mut h = harness(2, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let last = panel_thumb_rect(&h, "Page 2"); + h.hover_at(last.center() + egui::vec2(0.0, last.height() * 0.25)); + h.run_steps(2); + drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "a note"]); +} + +#[test] +fn files_dropped_beside_the_pages_panel_still_open_as_documents() { + let mut h = harness(2, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Over the page view, not the panel. + let thumb = panel_thumb_rect(&h, "Page 1"); + h.hover_at(egui::pos2(thumb.left() - 300.0, thumb.center().y)); + h.run_steps(2); + drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]); +} + fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 { h.state().views[0].grid_zoom() } From 2fee7af241f069d44cd29850b8b2ba1ac1765a52 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:18:44 +0200 Subject: [PATCH 020/207] Clamp extracted Indexed image samples to the declared palette range (#1581) * M10: clamp extracted Indexed image samples to hival Validate Indexed hival as an integer in 0..=255 and clamp raw palette samples before lookup. Preserve existing Decode, mask, and short-palette behavior. Refs #1201 (item 9 only). AI-generated by Codex (GPT-6). * ci: re-run (unrelated flaky test) * test: make reduce-size no-op regression deterministic --- crates/create/src/extract.rs | 15 ++-- crates/create/src/tests.rs | 131 +++++++++++++++++++++++++++++++++ crates/ui-egui/tests/create.rs | 34 +++++++-- 3 files changed, 169 insertions(+), 11 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index d9a96181ab..1034489458 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -179,8 +179,9 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> Space::Gray => rgb.extend_from_slice(&[v(0); 3]), Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), - Space::Indexed(base, table) => { - let i = px.first().copied().unwrap_or(0) as usize; + Space::Indexed(base, hival, table) => { + // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3). + let i = px.first().copied().unwrap_or(0).min(*hival) as usize; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { @@ -310,8 +311,8 @@ enum Space { Gray, Rgb, Cmyk, - /// Base space and lookup table (one byte per base component). - Indexed(Box, Vec), + /// Base space, highest valid palette index, and lookup table. + Indexed(Box, u8, Vec), } impl Space { @@ -352,12 +353,16 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { if matches!(base, Space::Indexed(..)) { return Err("nested indexed colour spaces aren't valid".into()); } + let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { + Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, + _ => return Err("an indexed colour space with an invalid hival".into()), + }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; - Ok(Space::Indexed(Box::new(base), table)) + Ok(Space::Indexed(Box::new(base), hival, table)) } Some(other) => unsupported(other), None => Err("a damaged colour space".into()), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 2d76055ec9..993c38d9f3 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -744,6 +744,137 @@ fn damaged_masks_and_sizes_never_panic() { } } +#[test] +fn indexed_image_export_clamps_samples_to_hival() { + for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.iter().next().unwrap().1.as_ref().unwrap(); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(4)); + dict.set(b"Height".to_vec(), Object::Int(1)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(2)); + dict.set( + b"ColorSpace".to_vec(), + Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::String(PdfString::literal(palette))]), + ); + // Default Decode preserves sample values0,1,2,3. Values2 and3 clamp to hival1. + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0b0001_1011]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + assert_eq!(exported_png_pixels(&out.images[0].data), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]); + } +} + +fn exported_png_pixels(bytes: &[u8]) -> Vec { + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let mut reader = decoder.read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(info.buffer_size()); + pixels +} + +fn indexed_export_fixture( + hival: Object, + palette: Vec, + stream_table: bool, + indirect_hival: bool, + bpc: i64, + samples: Vec, +) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.iter().next().unwrap().1.as_ref().unwrap(); + let table = if stream_table { + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &palette)))) + } else { + Object::String(PdfString::literal(palette)) + }; + let hival = if indirect_hival { Object::Ref(doc.add(hival)) } else { hival }; + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(4)); + dict.set(b"Height".to_vec(), Object::Int(1)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), hival, table])); + doc.set(image, Object::Stream(Stream::from_raw(dict, samples))); + (doc, image) +} + +#[test] +fn indexed_hival_resolves_indirect_values_and_stream_palettes() { + for (bpc, samples) in [(2, vec![0x1b]), (4, vec![0x01, 0x23]), (8, vec![0, 1, 2, 255])] { + let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], true, true, bpc, samples); + let (ext, bytes) = crate::extract::image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]); + } +} + +#[test] +fn indexed_hival_boundary_values_keep_valid_palette_entries() { + let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); + for high in [0, 255] { + let (doc, image) = indexed_export_fixture(Object::Int(high), palette.clone(), false, false, 8, vec![0, 1, 254, 255]); + let (_, bytes) = crate::extract::image_file(&doc, image).unwrap(); + let expected: Vec = [0u8, 1, 254, 255] + .into_iter() + .flat_map(|i| { + let i = i.min(high as u8); + [i, 255 - i, 17] + }) + .collect(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn indexed_hival_rejects_invalid_values_without_panicking() { + for high in [Object::Int(-1), Object::Int(256), Object::Real(1.0), Object::Null, Object::name("bad")] { + let (doc, image) = indexed_export_fixture(high, vec![255, 0, 0], false, false, 8, vec![0; 4]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival")); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1); + assert!(out.skipped[0].2.contains("invalid hival")); + } +} + +#[test] +fn indexed_hival_clamping_preserves_raw_colour_key_mask_samples() { + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") }; + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let (_, bytes) = crate::extract::image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255, 255]); +} + +#[test] +fn indexed_hival_missing_element_returns_export_error() { + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 8, vec![0; 4]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") }; + stream.dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB")])); + doc.set(image, Object::Stream(stream)); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival")); +} + +#[test] +fn indexed_hival_keeps_existing_short_palette_fallback() { + let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 2, vec![0x1b]); + let (_, bytes) = crate::extract::image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); +} + /// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an /// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index bfbba2e131..539ebb1e69 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -69,12 +69,36 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); } +struct ReduceDir(std::path::PathBuf); +impl ReduceDir { + fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..128 { + let path = std::env::temp_dir().join(format!("pdfcraft-reduce-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + match std::fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating Reduce test directory: {error}"), + } + } + panic!("no unused Reduce test directory after 128 attempts"); + } +} +impl Drop for ReduceDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// Open `name` and run Reduce File Size on it, saving to `out` without a dialog. fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> { let out2 = out.to_path_buf(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + // Keep metadata identical: changing ModDate can make a compact PDF one byte smaller. + app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000); app.open_bytes(name, None, bytes).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app @@ -105,9 +129,8 @@ fn reduce_file_size_writes_a_compact_copy() { enc.set_depth(png::BitDepth::Eight); enc.write_header().unwrap().write_image_data(&px).unwrap(); } - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); let h = reduce("photo.png", photo, &out); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); @@ -119,9 +142,8 @@ fn reduce_file_size_writes_a_compact_copy() { #[test] fn reduce_file_size_saves_nothing_when_the_file_is_already_small() { use egui_kittest::kittest::Queryable; - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); // A new text PDF is already compact: a copy would be no smaller (#490). let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out); assert!(!out.exists(), "no copy was written"); From 9bbd765ea4352d17834dc93e1371428cb45d646c Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Thu, 15 Oct 2026 06:19:13 +0000 Subject: [PATCH 021/207] Keep page labels and focused navigation consistent across reloads (#1667) * M3: await remote-link refusal in the UI regression Wait for the asynchronous notification before checking the existing no-tab and refusal-message assertions. Co-authored-by: Codex * M3: keep page label input consistent across reloads Co-authored-by: Codex --------- Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 32 ++- crates/ui-egui/src/chrome.rs | 26 +- crates/ui-egui/src/commands.rs | 12 +- crates/ui-egui/tests/document_links.rs | 1 + crates/ui-egui/tests/reload.rs | 88 +++++- crates/ui-egui/tests/viewer.rs | 355 ++++++++++++++++++++++++- docs/roadmap.md | 2 +- 8 files changed, 497 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 37ffaefa02..a975629455 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,6 +154,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. - **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. +- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3ae27c40ce..58de078271 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -299,6 +299,8 @@ pub struct DocView { pub organize: bool, pub highlight_fields: bool, pub page_input: String, + /// Select the replacement page label once if its editor remains focused. + pub(crate) page_input_reselect: bool, pub notice_dismissed: bool, /// The user has been told that the form's scripts were turned off (it is said once). pub scripts_off_noted: bool, @@ -593,6 +595,7 @@ impl DocView { organize: false, highlight_fields: defaults.highlight_fields, page_input: "1".into(), + page_input_reselect: false, notice_dismissed: false, scripts_off_noted: false, disk_changed: false, @@ -695,7 +698,6 @@ impl DocView { self.page_heights = info.pages.iter().map(|p| p.height).collect(); let last = self.page_count.saturating_sub(1); self.current = self.current.min(last); - self.page_input = (self.current + 1).to_string(); self.selected.retain(|p| *p <= last); // Text being typed on a page that no longer exists goes with the page. if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) { @@ -722,6 +724,7 @@ impl DocView { fresh.zoom = self.zoom; fresh.fit = self.fit; fresh.layout = self.layout; + fresh.continuous = self.continuous; fresh.rotation = self.rotation; fresh.current = self.current; fresh.organize = self.organize; @@ -742,6 +745,8 @@ impl DocView { *self = fresh; // Marks the kept images out of date and clamps the page to the new page count. self.document_changed(info); + self.page_input = self.current_page_label(info.pages.get(self.current).map(|page| page.label.as_str())); + self.page_input_reselect = true; } /// Pages an organize command acts on: the selection, or the current page. @@ -1027,7 +1032,6 @@ impl DocView { self.wheel.clear_motion(); self.goto = Some((page, 0.0)); self.current = page; - self.page_input = (page + 1).to_string(); } /// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled @@ -1112,9 +1116,25 @@ impl DocView { self.go_to_page(target); } - /// Go to the page typed in the page box: a page label (logical page numbers, as Acrobat - /// does), else a page number. `false` when it names no page. + /// Page-editor focus and drafts belong to a document, even while another tab is active. + pub(crate) fn page_input_id(&self) -> egui::Id { + egui::Id::new(("page-input", self.id.0)) + } + + /// The idle page box and unchanged-input lookup share the same label fallback. + pub(crate) fn current_page_label(&self, label: Option<&str>) -> String { + label.filter(|l| !l.is_empty()).map(str::to_owned).unwrap_or_else(|| (self.current + 1).to_string()) + } + + /// Retain the current page when `typed` exactly matches its displayed label, even if + /// another page has that label. Otherwise resolve a trimmed label (case-insensitive), + /// then a physical page number. `false` when it names no page. pub fn go_to_typed(&mut self, typed: &str, labels: &[String]) -> bool { + // Compare the displayed value before trimming: a label may itself contain spaces. + // Repeated labels occur in restarted sections and prefix-only ranges. + if self.current < self.page_count && self.current_page_label(labels.get(self.current).map(String::as_str)) == typed { + return true; + } let t = typed.trim(); let page = labels .iter() @@ -1137,7 +1157,6 @@ impl DocView { let page = page.min(self.page_count.saturating_sub(1)); self.goto = Some((page, 0.0)); self.current = page; - self.page_input = (page + 1).to_string(); true } @@ -3046,9 +3065,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if view.continuous { view.current = current; - if !ui.memory(|m| m.has_focus(egui::Id::new("page-input"))) { - view.page_input = (current + 1).to_string(); - } } resp.context_menu(|ui| { // Preparing a form: the selected field's menu. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 9617bf67dd..f4bfa22c35 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -696,8 +696,21 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.step_page(false); } ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted)); + let page_input_id = view.page_input_id(); + // Consume replacement selection once, including when the editor has lost focus. + let reselect = std::mem::take(&mut view.page_input_reselect); + // Navigation updates the idle display, never the text being edited. + if !ui.memory(|m| m.has_focus(page_input_id)) { + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } else if reselect { + // The replacement changed the label while egui retained this editor's cursor. + let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); + let end = egui::text::CCursor::new(view.page_input.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ui.ctx(), page_input_id); + } let edit = egui::TextEdit::singleline(&mut view.page_input) - .id(egui::Id::new("page-input")) + .id(page_input_id) .desired_width(34.0) .horizontal_align(Align::Center) .font(theme::medium(12.0)) @@ -709,12 +722,13 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, |ui| ui.add(edit)) .inner .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - // A page label first (logical page numbers, as Acrobat), then a number. - let typed = view.page_input.clone(); - if !view.go_to_typed(&typed, &labels) { - view.page_input = (view.current + 1).to_string(); + if r.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // A page label first (logical page numbers, as Acrobat), then a number. + let typed = view.page_input.clone(); + view.go_to_typed(&typed, &labels); } + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); } }); if let Some(id) = page_display { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a8deee258d..1788e7823f 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -214,9 +214,15 @@ impl PdfCraftApp { } } "view.focus_page_input" => { - if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get(i))) { - // The page box in the toolbar (chrome.rs); select its number so typing replaces it. - let id = egui::Id::new("page-input"); + if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get_mut(i))) { + // The page box in the toolbar (chrome.rs); select its label so typing replaces it. + let id = view.page_input_id(); + if !ctx.memory(|m| m.has_focus(id)) + && let Some(doc) = self.session.get(view.id) + { + // Navigation and this command can arrive before the next rail frame. + view.page_input = view.current_page_label(doc.info.pages.get(view.current).map(|p| p.label.as_str())); + } ctx.memory_mut(|m| m.request_focus(id)); let mut state = egui::TextEdit::load_state(&ctx, id).unwrap_or_default(); let len = view.page_input.chars().count(); diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs index c9bb64a3df..ab7987b94b 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/document_links.rs @@ -235,6 +235,7 @@ fn a_gotor_link_to_a_directory_or_device_is_refused_without_reading_it() { for target in targets { let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), target, "[0 /Fit]"); click_remote_link(&mut h); + support::wait_until(&mut h, "relative invalid target refusal", |app| app.toast.is_some()); assert_eq!(h.state().views.len(), 1, "{target} opens no tab"); let toast = &h.state().toast.as_ref().unwrap().0; assert!(toast.contains("not a regular file"), "{target}: {toast}"); diff --git a/crates/ui-egui/tests/reload.rs b/crates/ui-egui/tests/reload.rs index 03a3bf5f41..45c6ec56e1 100644 --- a/crates/ui-egui/tests/reload.rs +++ b/crates/ui-egui/tests/reload.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::time::{Duration, SystemTime}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{By, Queryable}; use pdfcraft_ui_egui::{PdfCraftApp, SaveTarget}; /// An `n`-page document with a proper xref table; page `i` shows "Page i+1", and with `notes` @@ -119,6 +119,92 @@ fn a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom() { assert_eq!((app.views[0].current, app.views[0].zoom), (2, 1.5), "at the same page and zoom"); } +#[test] +fn a_watched_label_replacement_keeps_the_display_mode_and_focused_page() { + use pdfcraft_engine::{Edit, LabelStyle, Session}; + use pdfcraft_ui_egui::canvas::display_token; + use std::sync::Arc; + + let labelled = |ranges: &[(usize, usize, LabelStyle)]| { + let mut source = Session::new().with_clock(|| 1_700_000_000); + let id = source.open("labels.pdf", None, Arc::new(fixture(5)), None).unwrap(); + for &(from, to, style) in ranges { + source.apply(id, Edit::NumberPages { from, to, style, prefix: String::new(), first: 1 }).unwrap(); + } + source.save_bytes(id).unwrap() + }; + let original = labelled(&[(0, 1, LabelStyle::LowerRoman), (2, 4, LabelStyle::Decimal)]); + let replacement = labelled(&[(0, 4, LabelStyle::LowerRoman)]); + for display in ["single", "continuous"] { + let file = TempPdf::new(&format!("focused-label-{display}"), &original); + let mut h = harness(&file.path); + let id = h.state().views[0].id; + h.state_mut().set_option("layout", display).unwrap(); + h.state_mut().set_option("zoom", "100").unwrap(); + h.state_mut().set_option("page", "4").unwrap(); + h.run_steps(3); + let view = &h.state().views[0]; + assert_eq!((view.current, display_token(view.layout, view.continuous())), (3, display)); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.bytes, original); + assert!(!doc.dirty); + assert_eq!((doc.can_undo(), doc.can_redo()), (None, None)); + assert!(h.state_mut().execute("view.focus_page_input")); + h.run_steps(3); + h.get(By::new().role(egui::accesskit::Role::TextInput).value("2")); + let editor = h.ctx.memory(|m| m.focused()).expect("the page editor is focused"); + + // A build atomically publishes a whole, clean PDF. The real watcher detects it. + let next = file.dir.join("next.pdf"); + std::fs::write(&next, replacement.as_slice()).unwrap(); + let published = std::fs::File::options().write(true).open(&next).unwrap(); + published.set_modified(SystemTime::now() + Duration::from_secs(60)).unwrap(); + drop(published); + std::fs::rename(next, &file.path).unwrap(); + for _ in 0..8 { + h.run_steps(1); + if h.state().session.get(id).unwrap().bytes == replacement { + break; + } + } + h.run_steps(2); + let app = h.state(); + let view = &app.views[0]; + let doc = app.session.get(id).unwrap(); + assert_eq!((app.views.len(), app.active, view.id), (1, Some(0), id)); + assert_eq!(doc.bytes, replacement, "the watcher loaded the replacement before checking navigation"); + assert_eq!((doc.info.pages.len(), doc.info.pages[3].label.as_str()), (5, "iv")); + // First causal negative: a single-page reader must not become a continuous reader. + assert_eq!((view.current, display_token(view.layout, view.continuous())), (3, display), "reload retains the physical page and display mode"); + assert_eq!(view.zoom, 1.0); + assert_eq!(view.page_input, "iv"); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(editor)); + h.get(By::new().role(egui::accesskit::Role::TextInput).value("iv")); + + // Keep the replacement editor's selection and then its caret, without re-focusing it. + h.event(egui::Event::Text("2".into())); + h.run_steps(2); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "2")); + h.event(egui::Event::Text("x".into())); + h.run_steps(1); + assert_eq!(h.state().views[0].page_input, "2x"); + h.key_press(egui::Key::Backspace); + h.run_steps(1); + assert_eq!(h.state().views[0].page_input, "2"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let app = h.state(); + assert_eq!((app.views[0].id, app.views[0].current, app.views[0].page_input.as_str()), (id, 1, "ii")); + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.path.as_deref(), Some(file.path.as_str())); + assert!(!doc.dirty); + assert_eq!((doc.can_undo(), doc.can_redo()), (None, None)); + assert_eq!(doc.bytes, replacement); + assert_eq!(app.session.save_bytes(id).unwrap(), replacement); + assert_eq!(std::fs::read(&file.path).unwrap(), replacement.as_ref().clone()); + } +} + #[test] fn a_half_written_file_waits_until_the_pdf_is_whole() { let file = TempPdf::new("partial", &fixture(2)); diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index a21f6b14ea..3544b82acf 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -2,7 +2,7 @@ //! cover page, a scroll position per tab. use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{By, Queryable}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; /// `n` pages of 200×300; page i says "Page i+1" plus "page" and "Pages" for find tests. @@ -302,6 +302,359 @@ fn layouts_fit_height_labels_and_system_theme() { assert_eq!(h.state().theme_preference, pdfcraft_ui_egui::theme::ThemePreference::System); } +fn page_input_focused(h: &Harness<'static, PdfCraftApp>) -> bool { + let focused = h.ctx.memory(|m| m.focused()); + let view = &h.state().views[h.state().active.unwrap()]; + // Also identifies the original editor when running this test-only patch on the baseline. + focused == Some(egui::Id::new(("page-input", view.id.0))) || focused == Some(egui::Id::new("page-input")) +} + +/// Exercise the real navigation editor, including its focus and Enter handling. +fn focus_page_input(h: &mut Harness<'static, PdfCraftApp>) { + let text = h.state().views[h.state().active.unwrap()].page_input.clone(); + h.get(By::new().role(egui::accesskit::Role::TextInput).value(&text)).focus(); + h.run_steps(1); + assert!(page_input_focused(h), "the actual page editor has focus"); +} + +fn type_page_input(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + focus_page_input(h); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + let old = h.state().views[h.state().active.unwrap()].page_input.clone(); + h.get(By::new().role(egui::accesskit::Role::TextInput).value(&old)).type_text(text); + h.run_steps(2); +} + +fn restart_page_labels(h: &mut Harness<'static, PdfCraftApp>) { + use pdfcraft_engine::{Edit, LabelStyle}; + let session = std::mem::take(&mut h.state_mut().session); + h.state_mut().session = session.with_clock(|| 1_700_000_000); + h.state_mut().active = Some(0); + for (from, to, style, first) in [(0, 1, LabelStyle::LowerRoman, 1), (2, 4, LabelStyle::Decimal, 1)] { + h.state_mut().apply_edit(Edit::NumberPages { from, to, style, prefix: String::new(), first }); + } + h.run_steps(3); +} + +#[test] +fn page_label_input_keeps_current_page_when_submitted_unchanged() { + let mut h = harness(); + restart_page_labels(&mut h); + // Physical page 3 has logical label 1. The old editor showed 3, then interpreted it as + // logical label 3 (physical page 5) when Enter was pressed without changing anything. + h.state_mut().views[0].go_to_page(2); + h.run_steps(3); + focus_page_input(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 2, "unchanged Enter must stay on the current physical page"); + + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap(); + let undo = before.can_undo().map(str::to_owned); + let dirty = before.dirty; + let before_bytes = h.state().session.save_bytes(id).unwrap(); + for (page, label) in ["i", "ii", "1", "2", "3"].into_iter().enumerate() { + h.state_mut().views[0].go_to_page(page); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, label); + focus_page_input(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, page, "{label}"); + assert_eq!(h.state().views[0].page_input, label); + } + let after = h.state().session.get(id).unwrap(); + assert_eq!(after.can_undo().map(str::to_owned), undo); + assert_eq!(after.dirty, dirty, "navigation does not edit the PDF"); + assert_eq!(h.state().session.save_bytes(id).unwrap(), before_bytes, "navigation preserves saved PDF bytes"); + let mut empty = pdfcraft_ui_egui::canvas::DocView::new(pdfcraft_engine::DocId(0), &Default::default(), Default::default()); + assert!(!empty.go_to_typed("1", &[]), "an empty view has no current page to retain"); +} + +#[test] +fn page_label_input_resolves_labels_numbers_and_restores_invalid_input() { + use pdfcraft_engine::{Edit, LabelStyle}; + let mut h = harness(); + restart_page_labels(&mut h); + h.state_mut().apply_edit(Edit::NumberPages { from: 4, to: 4, style: LabelStyle::Decimal, prefix: "A-".into(), first: 8 }); + h.run_steps(3); + for (typed, page, displayed) in [("ii", 1, "ii"), ("2", 3, "2"), ("A-8", 4, "A-8"), ("3", 2, "1"), ("missing", 2, "1")] { + type_page_input(&mut h, typed); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, page, "{typed}"); + assert_eq!(h.state().views[0].page_input, displayed, "{typed}"); + } + // Scripted page selection remains physical and one-based even when a numeric label differs. + h.state_mut().set_option("page", "2").unwrap(); + h.run_steps(3); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (1, "ii")); + + h.state_mut().apply_edit(Edit::NumberPages { from: 0, to: 1, style: LabelStyle::None, prefix: "Appendix".into(), first: 1 }); + h.state_mut().views[0].go_to_page(1); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, "Appendix"); + focus_page_input(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 1, "a duplicate current label must not resolve to its first occurrence"); + h.state_mut().views[0].go_to_page(2); + h.run_steps(3); + type_page_input(&mut h, "Appendix"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 0, "a changed label keeps the existing first-match lookup"); + + h.state_mut().views[0].go_to_page(0); + h.state_mut().apply_edit(Edit::NumberPages { from: 0, to: 0, style: LabelStyle::None, prefix: String::new(), first: 1 }); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, "1", "an empty label falls back to the physical number"); + focus_page_input(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 0, "the current fallback number must not jump to another page labelled 1"); +} + +#[test] +fn page_label_input_preserves_literal_whitespace_before_trimmed_lookup() { + use pdfcraft_render::{DocInfo, PageInfo}; + use pdfcraft_ui_egui::canvas::DocView; + // The importer trims label prefixes, so supply literal labels directly to the view. + for label in [" 1", "1 "] { + let info = DocInfo { + pages: [label, "ii", "1"] + .into_iter() + .map(|label| PageInfo { width: 200.0, height: 300.0, label: label.into(), crop: [0.0, 0.0, 200.0, 300.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + let labels: Vec = info.pages.iter().map(|page| page.label.clone()).collect(); + let mut view = DocView::new(pdfcraft_engine::DocId(0), &info, Default::default()); + view.document_replaced(&info); + assert_eq!(view.page_input, label, "the displayed label retains its literal whitespace"); + let unchanged = view.page_input.clone(); + assert!(view.go_to_typed(&unchanged, &labels)); + assert_eq!(view.current, 0, "unchanged spaces in {label:?} belong to the label"); + assert!(view.go_to_typed("1", &labels)); + assert_eq!(view.current, 2, "changed input keeps the existing trimmed label lookup"); + } +} + +#[test] +fn page_label_input_focus_command_refreshes_same_frame_navigation_and_preserves_a_draft() { + let mut h = harness(); + restart_page_labels(&mut h); + assert_eq!(h.state().views[0].page_input, "i"); + // No frame between navigation and the real command: it must not focus the old label. + h.state_mut().set_option("page", "3").unwrap(); + assert!(h.state_mut().execute("view.focus_page_input")); + h.run_steps(3); + assert!(page_input_focused(&h)); + assert_eq!(h.state().views[0].page_input, "1"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 2, "the focus command selects the new current label"); + + type_page_input(&mut h, "draft"); + h.state_mut().set_option("page", "4").unwrap(); + assert!(h.state_mut().execute("view.focus_page_input")); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 3); + assert_eq!(h.state().views[0].page_input, "draft", "re-focusing keeps text already being edited"); +} + +#[test] +fn page_label_input_new_document_does_not_inherit_or_commit_the_previous_draft() { + use pdfcraft_engine::{Edit, LabelStyle, Session}; + let mut h = harness(); + restart_page_labels(&mut h); + h.state_mut().views[0].go_to_page(3); + h.run_steps(3); + type_page_input(&mut h, "ii"); + let original_id = h.state().views[0].id; + let original_bytes = h.state().session.save_bytes(original_id).unwrap(); + + let mut source = Session::new().with_clock(|| 1_700_000_000); + let source_id = source.open("new-labels.pdf", None, std::sync::Arc::new(fixture(5)), None).unwrap(); + for (from, to, style) in [(0, 1, LabelStyle::LowerRoman), (2, 4, LabelStyle::Decimal)] { + source.apply(source_id, Edit::NumberPages { from, to, style, prefix: String::new(), first: 1 }).unwrap(); + } + h.state_mut().open_bytes("new-labels.pdf", None, source.save_bytes(source_id).unwrap().as_ref().clone()).unwrap(); + // Enter arriving with the switch must not submit the previous editor into this document. + h.key_press(egui::Key::Enter); + h.run_steps(3); + let next = h.state().active.unwrap(); + assert_ne!(h.state().views[next].id, original_id); + assert_eq!((h.state().views[next].current, h.state().views[next].page_input.as_str()), (0, "i")); + assert!(!page_input_focused(&h), "the new document's editor starts idle"); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "ii"), "the hidden valid draft is not committed"); + focus_page_input(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[next].current, 0, "unchanged Enter stays on the new document's first page"); + + h.get_by_label("a.pdf (edited)").click(); + h.run_steps(3); + assert_eq!(h.state().active, Some(0)); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "2")); + assert_eq!(h.state().session.save_bytes(original_id).unwrap(), original_bytes, "tab switching never edits the original PDF"); +} + +#[test] +fn page_label_input_preserves_typing_and_refreshes_after_navigation_and_tab_changes() { + let mut h = harness(); + restart_page_labels(&mut h); + h.state_mut().views[0].go_to_page(0); + h.run_steps(3); + type_page_input(&mut h, "draft"); + h.state_mut().views[0].go_to_page(3); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, "draft", "paging must not overwrite the focused editor"); + let editor_id = h.ctx.memory(|m| m.focused()).expect("focused page editor"); + h.ctx.memory_mut(|m| m.surrender_focus(editor_id)); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, "2", "an idle editor follows the current page"); + + type_page_input(&mut h, "unsent"); + h.get_by_label("b.pdf").click(); + h.run_steps(3); + assert_eq!(h.state().active, Some(1)); + assert_eq!(h.state().views[0].page_input, "unsent", "switching tabs keeps the hidden document's draft"); + assert_eq!(h.state().views[1].page_input, "1", "the new document displays its own label"); + h.state_mut().set_option("page", "2").unwrap(); + h.run_steps(3); + focus_page_input(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!((h.state().views[1].current, h.state().views[1].page_input.as_str()), (1, "2"), "default labels still work"); + h.get_by_label("a.pdf (edited)").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, "2", "returning to a document refreshes its idle label"); + + assert!(h.state_mut().views[0].view_history(false)); + h.run_steps(3); + assert_eq!(h.state().views[0].page_input, "i", "history navigation refreshes the label"); +} + +#[test] +fn page_label_input_same_document_replacement_refreshes_the_focused_label() { + use pdfcraft_engine::{Edit, LabelStyle, Session}; + use std::sync::Arc; + for (pages, roman, page, label) in [(5, false, 3, "4"), (5, true, 3, "iv"), (3, false, 2, "3")] { + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().views[0].go_to_page(3); + h.run_steps(3); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "4")); + focus_page_input(&mut h); + let editor_id = h.ctx.memory(|m| m.focused()).expect("focused page editor"); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap(); + assert!(!before.dirty); + assert_eq!((before.can_undo(), before.can_redo()), (None, None)); + + let replacement = if roman { + let mut source = Session::new().with_clock(|| 1_700_000_000); + let source_id = source.open("replacement.pdf", None, Arc::new(fixture(pages)), None).unwrap(); + source + .apply(source_id, Edit::NumberPages { from: 0, to: pages - 1, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }) + .unwrap(); + source.save_bytes(source_id).unwrap() + } else { + Arc::new(fixture(pages)) + }; + // The file watcher uses this same reload/replacement path without changing DocId. + let app = h.state_mut(); + app.session.reload(id, replacement.clone()).unwrap(); + let doc = app.session.get(id).unwrap(); + app.views[0].document_replaced(&doc.info); + h.run_steps(3); + assert_eq!(h.state().views[0].id, id); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(editor_id), "reload retains the actual editor's focus"); + assert!(page_input_focused(&h)); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (page, label), "replacement labels and page-count clamping"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!( + (h.state().views[0].current, h.state().views[0].page_input.as_str()), + (page, label), + "unchanged Enter retains the replacement's physical page" + ); + assert_eq!(h.state().views[0].id, id); + let after = h.state().session.get(id).unwrap(); + assert!(!after.dirty, "navigation does not edit the replacement"); + assert_eq!((after.can_undo(), after.can_redo()), (None, None)); + assert_eq!(h.state().session.save_bytes(id).unwrap(), replacement, "navigation preserves replacement PDF bytes"); + } +} + +#[test] +fn page_label_input_same_document_replacement_keeps_full_label_selected_for_typing() { + use pdfcraft_engine::{Edit, LabelStyle, Session}; + use std::sync::Arc; + for settling_frames in [3, 0] { + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().views[0].go_to_page(3); + h.run_steps(3); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "4")); + assert!(h.state_mut().execute("view.focus_page_input")); + h.run_steps(3); + assert!(page_input_focused(&h), "the real focus command focuses the page editor"); + let editor_id = h.ctx.memory(|m| m.focused()).expect("focused page editor"); + let range = egui::TextEdit::load_state(&h.ctx, editor_id).unwrap().cursor.char_range().unwrap(); + let ends = [range.primary.index, range.secondary.index]; + assert!(ends.contains(&egui::text::CCursor::new(0).index), "the old label selection starts at zero"); + assert!(ends.contains(&egui::text::CCursor::new(1).index), "the entire old one-character label is selected"); + let id = h.state().views[0].id; + let original = h.state().session.get(id).unwrap(); + assert!(!original.dirty); + assert_eq!((original.can_undo(), original.can_redo()), (None, None)); + + let mut source = Session::new().with_clock(|| 1_700_000_000); + let source_id = source.open("replacement.pdf", None, Arc::new(fixture(5)), None).unwrap(); + source.apply(source_id, Edit::NumberPages { from: 0, to: 4, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }).unwrap(); + let replacement = source.save_bytes(source_id).unwrap(); + let app = h.state_mut(); + app.session.reload(id, replacement.clone()).unwrap(); + let doc = app.session.get(id).unwrap(); + app.views[0].document_replaced(&doc.info); + // Cover the reproduced settled case and typing before the replacement's first frame. + if settling_frames > 0 { + h.run_steps(settling_frames); + } + assert_eq!(h.state().views[0].id, id); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(editor_id), "same-DocId reload retains the actual focused editor"); + assert!(page_input_focused(&h)); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "iv")); + + // Ordinary typing must replace the whole refreshed label without another focus or select-all. + h.event(egui::Event::Text("2".into())); + h.run_steps(if settling_frames == 0 { 1 } else { 2 }); + assert!(page_input_focused(&h)); + assert_eq!(h.state().views[0].current, 3, "typing has not submitted navigation yet"); + assert_eq!(h.state().views[0].page_input, "2", "typing replaces the entire replacement label"); + // Selection synchronization is consumed once: later input appends at the caret. + h.event(egui::Event::Text("x".into())); + h.run_steps(1); + assert!(page_input_focused(&h)); + assert_eq!(h.state().views[0].page_input, "2x", "a second character appends without selecting the label again"); + h.key_press(egui::Key::Backspace); + h.run_steps(1); + assert_eq!(h.state().views[0].page_input, "2", "Backspace removes only the second character"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (1, "ii"), "Enter goes to physical page two"); + assert_eq!(h.state().views[0].id, id); + let after = h.state().session.get(id).unwrap(); + assert!(!after.dirty, "navigation does not edit the replacement PDF"); + assert_eq!((after.can_undo(), after.can_redo()), (None, None)); + assert_eq!(h.state().session.save_bytes(id).unwrap(), replacement, "typing and navigation preserve replacement PDF bytes"); + } +} + #[test] fn two_page_view_steps_a_spread_at_a_time() { // #70: ⌘→ went to the right-hand page of the same spread, so the view never moved. diff --git a/docs/roadmap.md b/docs/roadmap.md index b67ef67f46..bccae8026a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -93,7 +93,7 @@ total matches the parity estimate. | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | From 760cde5adeb15cc6eaea3d4e612eaf1e57ee1791 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:19:24 +0200 Subject: [PATCH 022/207] M3: keep the full final spread reachable with unequal page heights (#1672) Related #187: this corrects the reproduced unreachable tall-left content, not the reported backward-page flash. AI-assisted by Codex (GPT-6). --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/tests/view.rs | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 58de078271..45f5fda844 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2265,7 +2265,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; let (y_shift, content_h) = if view.continuous { - (0.0, rects.last().map(|r| r.bottom() + MARGIN).unwrap_or(0.0)) + (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) } else { // Only the current row scrolls into view; the content is its bounding box plus margins. match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index d3744dd2ef..a48a86c07a 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1100,3 +1100,75 @@ fn sidebar_collapse_and_resize_preserves_scroll_position() { let rel_y_reopened = rect_reopened.min.y - vp_reopened.min.y; assert!((rel_y_reopened - rel_y_before).abs() < 1.0, "page 2 scroll position preserved on reopen: before {rel_y_before}, after {rel_y_reopened}"); } + +/// Synthetic unequal-height spreads, generated without font or image assets. +fn final_spread_at_scroll_end(heights: &[u32], cover: bool) -> (Vec<(usize, egui::Rect)>, egui::Rect) { + let kids = (0..heights.len()).map(|i| format!("{} 0 R", i + 3)).collect::>().join(" "); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{kids}] /Count {} >> endobj\n", + heights.len() + ); + for (i, height) in heights.iter().enumerate() { + pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 {height}] >> endobj\n", i + 3); + } + pdf += "trailer << /Root 1 0 R >>\n%%EOF"; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("unequal-spreads.pdf", None, pdf.as_bytes().to_vec()).unwrap(); + for (key, value) in [("left", "closed"), ("panel", "none"), ("layout", "two-up"), ("zoom", "200")] { + app.set_option(key, value).unwrap(); + } + app.set_option("cover", if cover { "on" } else { "off" }).unwrap(); + app + }); + h.run_steps(12); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + for _ in 0..4 { + h.event(wheel(MouseWheelUnit::Line, -1000.0, TouchPhase::Move)); + h.run_steps(45); + } + let view = &h.state().views[0]; + let last = heights.len() - 1; + let first = if cover { last - (last + 1) % 2 } else { last - last % 2 }; + let pages = (first..=last).map(|page| (page, view.page_screen_rect(page).expect("final spread is visible"))).collect(); + (pages, view.viewport_rect()) +} + +fn assert_final_spread_bottom_reachable(heights: &[u32], cover: bool) { + let (pages, viewport) = final_spread_at_scroll_end(heights, cover); + let bottom = pages.iter().map(|(_, rect)| rect.bottom()).fold(0.0_f32, f32::max); + assert!( + bottom <= viewport.bottom() + 1.0, + "final spread remains below the viewport: heights={heights:?}, cover={cover}, pages={pages:?}, viewport={viewport:?}" + ); + // The maximum-height page reaches the bottom margin after the scroll offset settles. + assert!((viewport.bottom() - bottom - 28.0).abs() < 1.0, "scroll end leaves the bottom margin: pages={pages:?}, viewport={viewport:?}"); +} + +#[test] +fn two_up_scroll_end_reaches_a_taller_left_page() { + assert_final_spread_bottom_reachable(&[400, 400, 400, 100], false); +} + +#[test] +fn two_up_scroll_end_reaches_a_taller_right_page() { + assert_final_spread_bottom_reachable(&[400, 400, 100, 400], false); +} + +#[test] +fn two_up_scroll_end_reaches_equal_height_pages() { + assert_final_spread_bottom_reachable(&[400, 400, 400, 400], false); +} + +#[test] +fn two_up_scroll_end_reaches_an_unpaired_final_page() { + assert_final_spread_bottom_reachable(&[400, 400, 400], false); +} + +#[test] +fn two_up_scroll_end_with_cover_reaches_a_taller_left_page() { + assert_final_spread_bottom_reachable(&[400, 400, 400, 400, 100], true); +} From 740117620db889b5ea9dd1505f1019e47d682961 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:19:34 +0200 Subject: [PATCH 023/207] Text selection: dragging to the top or bottom edge of the view scrolls it (part of #1670) (#1675) --- crates/ui-egui/src/autoscroll.rs | 38 +++++++++++++++- crates/ui-egui/src/canvas.rs | 15 +++++++ crates/ui-egui/tests/ui_group/text_select.rs | 47 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/autoscroll.rs b/crates/ui-egui/src/autoscroll.rs index 139bdaed8b..7eea563ee6 100644 --- a/crates/ui-egui/src/autoscroll.rs +++ b/crates/ui-egui/src/autoscroll.rs @@ -1,6 +1,7 @@ //! Middle-button scrolling for the document viewport: Linux latches auto-scroll, other //! platforms pan while the button is held. Either way the page tools never see the button, -//! because egui's drag responses accept every pointer button. +//! because egui's drag responses accept every pointer button. Also the edge scrolling of a +//! text-selection drag. use egui::{Context, CursorIcon, Event, Key, PointerButton, Pos2, Stroke, Vec2, vec2}; @@ -187,6 +188,27 @@ impl AutoScroll { } } +/// Within this distance of the viewport's top or bottom edge, a text-selection drag scrolls. +const EDGE: f32 = 30.0; +/// Points per second for each point the pointer is into the edge band or past the viewport. +const EDGE_SPEED: f32 = 20.0; + +/// How far a selection drag scrolls this frame with the pointer at `y`, near or past the top or +/// bottom of `viewport` (#1670); faster the farther it goes. egui's delta moves the content. +pub(crate) fn edge_delta(y: f32, viewport: egui::Rect, dt: f32) -> f32 { + if !y.is_finite() || !dt.is_finite() || viewport.height() < 4.0 * EDGE { + return 0.0; + } + let distance = if y > viewport.bottom() - EDGE { + y - (viewport.bottom() - EDGE) + } else if y < viewport.top() + EDGE { + y - (viewport.top() + EDGE) + } else { + return 0.0; + }; + -distance.clamp(-MAX_DISPLACEMENT, MAX_DISPLACEMENT) * EDGE_SPEED * dt.clamp(0.0, 0.05) +} + fn scroll_delta(displacement: f32, dt: f32) -> f32 { if !displacement.is_finite() || !dt.is_finite() { return 0.0; @@ -281,6 +303,20 @@ mod tests { assert!(!scroll.escape(&ctx)); } + #[test] + fn edge_delta_scrolls_toward_the_edge_the_pointer_is_at() { + let viewport = egui::Rect::from_min_max(egui::pos2(0.0, 100.0), egui::pos2(400.0, 500.0)); + assert_eq!(edge_delta(300.0, viewport, 0.016), 0.0, "nothing away from the edges"); + assert!(edge_delta(495.0, viewport, 0.016) < 0.0, "the bottom edge moves the content up"); + assert!(edge_delta(105.0, viewport, 0.016) > 0.0, "the top edge moves it down"); + assert!(edge_delta(600.0, viewport, 0.016) < edge_delta(495.0, viewport, 0.016), "faster past the viewport"); + for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(edge_delta(invalid, viewport, 0.016), 0.0); + assert_eq!(edge_delta(495.0, viewport, invalid), 0.0); + } + assert!(edge_delta(f32::MAX, viewport, 0.016).is_finite()); + } + #[test] fn chromium_curve_is_gentle_near_the_anchor_and_accelerates_farther_away() { // Reference speeds in screen points/second from Chromium's distance exponent (2.2), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 45f5fda844..959337e2fb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3055,6 +3055,21 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } } + // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view + // so it can reach text off screen (#1670); the selection follows the pointer on its page. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && let Some(p) = ui.input(|i| i.pointer.latest_pos()) + { + let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); + if dy != 0.0 { + ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); + // Keep scrolling while the pointer rests there and sends no events. + ui.ctx().request_repaint(); + } + } // The page navigated to stays current while no page shows more of itself, so the next // page step starts from it rather than from a page further down the screen (#188). // It also stays current while it is wholly on screen: a short page (a cheque) gone to diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index a98b2f8ed0..b77d7f05a4 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -267,3 +267,50 @@ fn a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page() { h.run_steps(2); assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); } + +/// One 300×600 page with a line of Helvetica text at its top and another at its bottom. +fn tall_page() -> Vec { + let content = "BT /F1 14 Tf 20 570 Td (Top line) Tj 0 -550 Td (Bottom line) Tj ET"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 600] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() +} + +/// Issue #1670: holding a selection drag at the bottom edge of the view scrolls it, and the +/// selection follows the pointer down to text that was off screen. +#[test] +fn a_selection_drag_held_at_the_bottom_edge_scrolls_the_view() { + let (mut h, _) = harness_with(tall_page(), |app| { + app.set_option("layout", "continuous").unwrap(); + app.set_option("zoom", "200").unwrap(); + }); + let view = &h.state().views[0]; + let viewport = view.viewport_rect(); + assert!(view.page_screen_rect(0).expect("page on screen").bottom() > viewport.bottom() + 100.0, "the page must not fit"); + // "Top" (glyphs 0..=2). + let (from, to) = (view.glyph_screen_pos(0, 0).expect("glyph on screen"), view.glyph_screen_pos(0, 2).expect("glyph on screen")); + press_and_move(&mut h, from, to); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Top")); + let edge = egui::pos2(to.x, viewport.bottom() - 2.0); + let before = page_top(&h, 0); + h.hover_at(edge); + h.run_steps(120); + assert!(page_top(&h, 0) < before - 100.0, "the view scrolls while the pointer rests at its edge ({before} -> {})", page_top(&h, 0)); + let held = h.state().views[0].selected_text().expect("still selected"); + // The pointer stays above the "t" of "Top", so the selection ends at the "t" of "Bottom". + assert_eq!(held, "Top line\nBot", "the selection reaches the bottom line"); + h.event(Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); +} From 44fbacfffd008c9ed8e83be784b09a514a764fba Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:19:45 +0200 Subject: [PATCH 024/207] Edit text & images: a dragged image's pixels follow its frame instead of waiting for the release (part of #1671) (#1677) --- crates/ui-egui/src/canvas.rs | 5 +++ crates/ui-egui/src/edit_text_ui.rs | 22 ++++++++++ crates/ui-egui/tests/editing.rs | 68 ++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 959337e2fb..2e23c2a97e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -888,6 +888,11 @@ impl DocView { self.thumbs.get(&page).map(|t| t.tex.id()) } + /// The whole-page raster on screen for `page` (the low-res backdrop of a tiled page), if any. + pub(crate) fn page_raster(&self, page: usize) -> Option { + self.pages.get(&page).map(|p| p.tex.id()) + } + /// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the /// thumbnail. pub(crate) fn page_preview(&self, page: usize) -> Option { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c50528ec..9a0870880f 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -527,6 +527,28 @@ pub(crate) fn image_input( let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); let visible = preview.intersect(xf.rect); if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + if preview != b + && let Some(tex) = view.page_raster(page) + { + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + clipped.rect_filled(b, CornerRadius::ZERO, Color32::WHITE); + let mut mesh = egui::Mesh::with_texture(tex); + for (from, to) in [ + (b.left_top(), preview.left_top()), + (b.right_top(), preview.right_top()), + (b.right_bottom(), preview.right_bottom()), + (b.left_bottom(), preview.left_bottom()), + ] { + let (u, v) = xf.screen_to_norm(from); + mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clipped.add(egui::Shape::mesh(mesh)); + } painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); handles(preview); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 2708b6c3db..7c2f96f34d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3929,6 +3929,74 @@ fn cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop() { assert_eq!(reopened.get(re).unwrap().page_images(0), h.state().session.get(id).unwrap().page_images(0)); } +/// While a corner is dragged, the image's pixels are drawn in the blue frame, not left at their +/// old size until release (#1671). +#[test] +fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels_drawn(true)).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].set_zoom(1.2); + h.run_steps(4); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); + let p = at(95.0, 195.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert!(h.state().views[0].image_selection.is_some()); + let frame = |h: &Harness<'static, PdfCraftApp>| { + h.output().shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 20.0 => Some(r.rect), + _ => None, + }) + }; + // Textured meshes smaller than the page: the page raster itself always covers the page. + let pictures = |h: &Harness<'static, PdfCraftApp>| -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.calc_bounds()), + _ => None, + }) + .filter(|b| b.width() < page.width() - 1.0) + .collect() + }; + let start = frame(&h).expect("the selected image has a frame"); + let (grip, fixed) = (start.right_top(), start.left_bottom()); + for factor in [0.6, 1.3] { + let to = fixed + (grip - fixed) * factor; + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip + (to - grip) * (k as f32 / 4.0)); + h.run_steps(1); + } + let live = frame(&h).expect("the drag shows its frame"); + assert!((live.width() - start.width() * factor).abs() < 1.0, "the frame follows the grip: {live:?} from {start:?}"); + let drawn = pictures(&h); + assert!( + drawn.iter().any(|b| (b.min - live.min).length() < 0.5 && (b.max - live.max).length() < 0.5), + "the picture is drawn in the frame {live:?}, not only at {drawn:?}" + ); + h.drop_at(to); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + } +} + /// Exercise actual pointer gestures on each cropped placement, without capturing private pixels. #[test] #[ignore = "requires an explicitly supplied local PDF with cropped artwork"] From f48b76c146a011a04d13d08bfb2bb2a541713b5b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:20:09 +0200 Subject: [PATCH 025/207] Text selection: a drag after a double- or triple-click extends by whole words or lines (fixes #1685) (#1689) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 34 +++++++++++++------ crates/ui-egui/tests/ui_group/text_select.rs | 35 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2e23c2a97e..7b66c79a73 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -266,6 +266,16 @@ impl Selection { } } +/// The glyphs `clicks` quick clicks select around glyph `a`: two the word, three the line, more +/// the page. +fn click_unit(text: &PageText, clicks: u32, a: usize) -> Option<(usize, usize)> { + match clicks { + 2 => text.word_at(a), + 3 => text.line_at(a), + _ => text.glyphs.len().checked_sub(1).map(|last| (0, last)), + } +} + struct PageTex { tag: u64, tex: TextureHandle, @@ -2840,11 +2850,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Multi-click highlights immediately on pointer press instead of release (#1024). let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; if clicks >= 2 && over_text { - let span = text.nearest(vx, vy).and_then(|a| match clicks { - 2 => text.word_at(a), - 3 => text.line_at(a), - _ => text.glyphs.len().checked_sub(1).map(|last| (0, last)), - }); + let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); if let Some((first, last)) = span { view.selection = Some(Selection::run(i, first, last)); } @@ -2862,10 +2868,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; } } + let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; if resp.dragged() && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i) { match &mut sel.span { + // After a multi-click the drag extends by its unit (#1685), as in + // Acrobat and browsers: whole words or lines, always keeping the one + // clicked, which holds the anchor whichever way the drag went. + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(h) = text.nearest(vx, vy) + && let Some((first, last)) = click_unit(&text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(&text, clicks, h).unwrap_or((h, h)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } Span::Run { head, .. } => { if let Some(h) = text.nearest(vx, vy) { *head = h; @@ -2901,11 +2919,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // (a highlight made by the double-click left its word selected). if release_clicks > 0 && clicks >= 2 && over_text { if view.selection.is_none() { - let span = text.nearest(vx, vy).and_then(|a| match clicks { - 2 => text.word_at(a), - 3 => text.line_at(a), - _ => text.glyphs.len().checked_sub(1).map(|last| (0, last)), - }); + let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); if let Some((first, last)) = span { view.selection = Some(Selection::run(i, first, last)); } diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index b77d7f05a4..e9e4ecdbd0 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -314,3 +314,38 @@ fn a_selection_drag_held_at_the_bottom_edge_scrolls_the_view() { h.run_steps(2); assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); } + +/// Click at `at`, then press there again and drag to `to` before releasing: a double-click drag. +fn double_click_drag(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, to: Pos2) { + click(h, at); + press_and_move(h, at, to); + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// Issue #1685: dragging after a double-click extends the selection a word at a time, and the +/// double-clicked word stays selected whichever way the drag goes. +#[test] +fn a_double_click_drag_extends_by_whole_words_both_ways() { + let (mut h, _) = harness(|_| {}); + let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen"); + // "The quick brown fox": the "u" of "quick" is glyph 5, the "o" of "brown" glyph 12. + let (u, o) = (glyph(&h, 5), glyph(&h, 12)); + double_click_drag(&mut h, u, o); + let right = h.state().views[0].selected_text(); + h.run_steps(60); + double_click_drag(&mut h, o, u); + let left = h.state().views[0].selected_text(); + assert_eq!([right.as_deref(), left.as_deref()], [Some("quick brown"); 2], "dragging right, then left"); +} + +/// Issue #1685: after a triple-click the drag extends a whole line at a time. +#[test] +fn a_triple_click_drag_extends_by_whole_lines() { + let (mut h, at) = harness(|_| {}); + // A glyph inside "jumps over the dog", on the second line. + let below = h.state().views[0].glyph_screen_pos(0, 23).expect("glyph on screen"); + click(&mut h, at); + double_click_drag(&mut h, at, below); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox\njumps over the dog")); +} From 9c875656de6967b54abe3e818dd102f7f13de609 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 15 Oct 2026 08:20:31 +0200 Subject: [PATCH 026/207] fix(render, ui): form-heavy documents draw and fill fast (spot-colour images, widget placement, Fields panel) (#1691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(render): convert spot-colour images once per pixel value (vendored hayro-interpret patch 28) An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is now converted to RGB through a table of every possible pixel value instead of pixel by pixel. A Separation or DeviceN tint transform is a PDF function, often a PostScript calculator program, and the image decoder ran it once per pixel. Fillable character sheets draw their background art as an 8-bit DeviceN image: one with a 3304 x 4105 background took 3.8 s and 430 MB to draw each page (Poppler: 0.27 s), when opening it and again after every field was filled. It now draws in 0.11 s and 100 MB, pixel for pixel the same. The table decodes each sample through the same `decode_sample` as the per-pixel path, keeps `fix_image_length`'s rules for short and overlong data (the pixels past the data are converted as before), and is used only when the image has more pixels than the table has entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8 * fix(render): place widgets without /P in one pass over the pages Opening a document, the inspector finds the page of a form field whose widget has no /P by searching every page's /Annots for it, once per field: O(fields x annotations). A form of 8,000 such fields took 1.17 s to open instead of 0.27 s. The pages' /Annots are now read once, on the first such field, into a map from annotation to page. A widget that several pages list is now placed on the first of them, as pdfcraft-forms places it; the search took whichever page a HashMap visited first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8 * fix(ui): the Fields panel keeps up with large forms Every frame the Fields panel searched the form for each row by name, once to sort the rows into tab order and again to find what a click selects: O(fields²) a frame. It also laid out, drew and built the tooltip of every row, scrolled into view or not. With 8,000 fields the app used 13.4 s of CPU for 20 pointer moves with the panel open (0.2 s with it closed). The rows are now worked out by `field_rows`, which pairs each row with the first form field of its name through one map a frame, as the engine pairs them, and a row out of view is allocated and keeps its clicks, keyboard focus and accessibility label but isn't drawn and builds no tooltip. The same 20 moves now take 0.86 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8 * docs: roadmap entries for faster form-heavy documents Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8 * fix(render): convert each lookup-table entry on its own (vendored hayro-interpret patch 28) Patch 28 converted the whole table of possible pixel values in one batch. Separation and DeviceN `convert_f32` concatenate each tint result, and a Type 4 program that returns the wrong number of components is only warned about, so a malformed result for a value the image never uses shifted every later entry. A 32 x 32 image whose only sample (255) is red drew black because tint 0 returned one component (PR #1691 review). `convert_each` now converts the table one entry at a time, so a malformed entry changes only itself. The largest table (65,536 entries, a two-ink DeviceN image over DeviceCMYK) takes about 2 ms longer. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/render/src/inspect.rs | 97 +++++++++-- crates/render/src/raster.rs | 148 +++++++++++++++++ crates/ui-egui/src/panels.rs | 218 ++++++++++++++++++++----- docs/roadmap.md | 2 + vendor/README.md | 2 +- vendor/hayro-interpret/src/x_object.rs | 170 ++++++++++++++++--- 7 files changed, 566 insertions(+), 72 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a975629455..242afd39db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. - **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. +- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index a3af3876b0..eefc2e923d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -5,6 +5,7 @@ //! remains the compatibility fallback. When neither can inspect a renderable file, panels //! are empty and `warnings` says why. +use std::cell::OnceCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; @@ -499,13 +500,17 @@ struct Inspector<'a> { /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. /// Looking each name up by walking the tree was O(links × names): minutes on manuals. named: HashMap, &'a Object>, + /// The page of each annotation (the first page that lists it), built when a field's widget + /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × + /// annotations): a second on a form of 8,000 fields. + annotation_pages: OnceCell>, } impl<'a> Inspector<'a> { fn new(doc: &'a dyn Structure) -> Self { let page_ids = doc.page_ids(); let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new() }; + let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; let mut named = HashMap::new(); if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) @@ -1184,17 +1189,25 @@ impl<'a> Inspector<'a> { out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); } + /// The first page whose `/Annots` lists the field or one of its kids. fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { - let targets: HashSet = - std::iter::once(field).chain(kids.iter()).filter_map(|o| if let Object::Reference(id) = o { Some(*id) } else { None }).collect(); - if targets.is_empty() { - return None; + let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); + std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min() + } + + /// Each annotation listed in a page's `/Annots`, with the first page that lists it. + fn annotation_pages(&self) -> HashMap { + let mut pages = HashMap::new(); + for (idx, &pid) in self.page_ids.iter().enumerate() { + let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; + let Ok(annots) = self.resolve(annots).as_array() else { continue }; + for a in annots { + if let Object::Reference(id) = a { + pages.entry(*id).or_insert(idx); + } + } } - self.page_index.iter().find_map(|(&pid, &idx)| { - let annots = self.doc.get_dictionary(pid).ok()?.get(b"Annots").ok()?; - let arr = self.resolve(annots).as_array().ok()?; - arr.iter().any(|a| matches!(a, Object::Reference(id) if targets.contains(id))).then_some(idx) - }) + pages } /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an @@ -2062,6 +2075,70 @@ trailer << /Root 1 0 R >> assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); } + /// A `Structure` that counts the objects read through it. + struct CountingReads<'a> { + doc: &'a Document, + reads: std::cell::Cell, + } + + impl Structure for CountingReads<'_> { + fn trailer(&self) -> &Dictionary { + &self.doc.trailer + } + fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { + self.reads.set(self.reads.get() + 1); + self.doc.get_object(id) + } + fn encrypted(&self) -> bool { + false + } + } + + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' + /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a + /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. + #[test] + fn widgets_without_a_page_entry_are_placed_in_one_pass() { + const PAGES: usize = 100; + const PER_PAGE: usize = 10; + let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; + let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); + let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); + pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); + let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); + pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); + for p in 0..PAGES { + let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); + // The last page lists the first page's first widget too. + if p == PAGES - 1 { + annots.push(format!("{} 0 R", field(0, 0))); + } + pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); + } + for p in 0..PAGES { + for k in 0..PER_PAGE { + pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); + } + } + pdf += "trailer << /Root 1 0 R >>\n%%EOF"; + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; + let mut info = DocInfo { + pages: (0..PAGES) + .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) + .collect(), + ..Default::default() + }; + Inspector::new(&counting).fill(&mut info); + let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); + let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); + assert_eq!(placed, want); + // A handful of reads per page and field; searching the pages for each field read the + // page dictionaries about 50,000 times. + let reads = counting.reads.get(); + assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); + } + #[test] fn lazy_structure_matches_the_compatibility_inspector() { // Each fixture as lopdf writes it, and with its objects in object streams. diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 4ad7196629..d1425717d6 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -5579,6 +5579,154 @@ endstream endobj assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); } + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. + fn render_first_page(pdf: Vec) -> RenderedPage { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(out.error.is_none(), "{:?}", out.error); + out + } + + /// A spot-colour image runs its tint transform once per possible sample, not once per pixel + /// (vendored hayro-interpret patch 28). Fillable character sheets draw their background art + /// as an 8-bit DeviceN image whose tint transform is a PostScript calculator program: run for + /// each of a 3304 × 4105 background's pixels, every page took four seconds and 430 MB to + /// draw, again after each field was filled. This image (2048 × 2048, a 9,000-operator + /// program) took about 40 seconds that way; from a 256-entry table it takes milliseconds. + #[test] + fn spot_colour_images_run_their_tint_transform_once_per_sample_value() { + const SIDE: usize = 2048; + // 8,990 operators that change nothing (the calculator allows 10,000 a run), then + // tint t → RGB (1 − t, t, 0). + let program = format!("{{ {}dup 1 exch sub exch 0 }}", "dup pop ".repeat(4495)); + // Each row: tint 0 on the left half, tint 1 on the right. + let row: Vec = [vec![0u8; SIDE / 2], vec![255u8; SIDE / 2]].concat(); + let data = row.repeat(SIDE); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width {SIDE} /Height {SIDE} /ColorSpace [/Separation /Spot /DeviceRGB 6 0 R] /BitsPerComponent 8 /Length {} >>", + data.len() + ); + let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); + let pdf = + image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program.as_bytes())]); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(render_first_page(pdf)); + }); + let out = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("a spot-colour image must not run its tint transform for every pixel"); + let w = out.width as usize; + assert_eq!(rgb_at(&out.rgba, w, 10, 35), [255, 0, 0], "tint 0"); + assert_eq!(rgb_at(&out.rgba, w, 120, 35), [0, 255, 0], "tint 1"); + } + + /// Images converted through patch 28's table keep the colour of every sample: the samples of + /// a pixel in order, each through its own `/Decode` range, rows that end mid-byte, and an image + /// whose data stops part-way through a row (that row is padded with decoded zeros, as before). + #[test] + fn images_converted_through_a_table_keep_every_samples_colour() { + let close = |got: [u8; 3], want: [u8; 3], at: (usize, usize)| { + assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1), "pixel {at:?}: got {got:?}, want {want:?}"); + }; + // DeviceN of two inks at 4 bits (256 possible pixels) shown as RGB (a, b, 0), the first + // ink's range reversed. 32 × 16 pixels, each drawn 4 pt square. + let mut data = Vec::new(); + for y in 0..16 { + for x in 0..32 { + let (a, b) = (x % 16, (x / 16 + 2 * y) % 16); + data.push(((a << 4) | b) as u8); + } + } + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 32 /Height 16 /ColorSpace [/DeviceN [/A /B] /DeviceRGB 6 0 R] /BitsPerComponent 4 /Decode [1 0 0 1] /Length {} >>", + data.len() + ); + let pdf = image_cache_pdf( + "q 128 0 0 64 0 0 cm /A Do Q", + "<< /XObject << /A 5 0 R >> >>", + &[(&image, &data), ("<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length 5 >>", b"{ 0 }")], + ); + let out = render_first_page(pdf); + let w = out.width as usize; + for y in 0..16 { + for x in 0..32 { + let (a, b) = (x % 16, (x / 16 + 2 * y) % 16); + // The 64 pt image sits at the bottom of the 70 pt page. + close(rgb_at(&out.rgba, w, 4 * x + 2, 6 + 4 * y + 2), [(255 - 17 * a) as u8, (17 * b) as u8, 0], (x, y)); + } + } + + // DeviceGray at 2 bits, reversed: 31 pixels (62 bits, so each row ends mid-byte) by 6. + let sample = |x: usize, y: usize| (x + y) % 4; + let mut data = Vec::new(); + for y in 0..6 { + let mut row = [0u8; 8]; + for x in 0..31 { + row[x / 4] |= (sample(x, y) as u8) << (6 - 2 * (x % 4)); + } + data.extend_from_slice(&row); + } + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 31 /Height 6 /ColorSpace /DeviceGray /BitsPerComponent 2 /Decode [1 0] /Length {} >>", + data.len() + ); + let pdf = image_cache_pdf("q 124 0 0 60 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data)]); + let out = render_first_page(pdf); + let w = out.width as usize; + for y in 0..6 { + for x in 0..31 { + // The 60 pt image at the bottom of the page: rows 10 pt high. + close(rgb_at(&out.rgba, w, 4 * x + 2, 10 + 10 * y + 5), [(255 - 85 * sample(x, y)) as u8; 3], (x, y)); + } + } + + // DeviceGray at 8 bits through the range [0.2 0.8] (so not drawn from the raw bytes): + // 31 × 12 declared, data for 8 rows and the first 12 pixels of the 9th. + let sample = |x: usize, y: usize| (x * 7 + y * 3) % 256; + let mut data = Vec::new(); + for y in 0..9 { + let row: Vec = (0..31).map(|x| sample(x, y) as u8).collect(); + data.extend_from_slice(if y < 8 { &row[..] } else { &row[..12] }); + } + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 31 /Height 12 /ColorSpace /DeviceGray /BitsPerComponent 8 /Decode [0.2 0.8] /Length {} >>", + data.len() + ); + let pdf = image_cache_pdf("q 124 0 0 60 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data)]); + let out = render_first_page(pdf); + let w = out.width as usize; + for y in 0..9 { + for x in 0..31 { + // The rest of the 9th row is decoded zero (black), not sample 0 (0.2 under this range). + let gray = if y < 8 || x < 12 { (51.0 + 0.6 * sample(x, y) as f64).round() as u8 } else { 0 }; + // The rows present keep the declared rows' height: 60 pt / 12. + close(rgb_at(&out.rgba, w, 4 * x + 2, 10 + 5 * y + 2), [gray; 3], (x, y)); + } + } + } + + /// Patch 28's table converts each entry on its own: a tint transform that gives the wrong + /// number of components for a sample value the image never uses leaves the colours of the + /// values it does use alone. Converted as one batch, the one component tint 0 gives here + /// shifted every later entry, and tint 1's red came out black (PR #1691 review). + #[test] + fn a_malformed_tint_for_an_unused_sample_keeps_the_used_colours() { + // Tint 0 gives one component instead of the three `/Range` declares; any other tint red. + let program = b"{ dup 0 eq { pop 0 } { pop 1 0 0 } ifelse }"; + // 32 × 32 = 1,024 pixels, more than the table's 256 entries, all sample 255 (tint 1). + let data = [255u8; 32 * 32]; + let centre = |space: &str| { + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 32 /Height 32 /ColorSpace [{space} /DeviceRGB 6 0 R] /BitsPerComponent 8 /Length {} >>", + data.len() + ); + let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); + let pdf = image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program)]); + let out = render_first_page(pdf); + rgb_at(&out.rgba, out.width as usize, 65, 35) + }; + assert_eq!([centre("/Separation /Spot"), centre("/DeviceN [/Spot]")], [[255, 0, 0]; 2], "Separation, then one-ink DeviceN"); + } + /// A Japanese CID font that isn't embedded (Adobe-Japan1, as `HeiseiMin-W3` with /// `UniJIS-UCS2-H` in #260's test file) drew nothing: hayro's substitutes for fonts that /// aren't embedded are Latin-only. With craft-fonts (the build input release builds embed), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index cf71f99a68..dc989d660e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1239,9 +1239,7 @@ fn fields( empty(ui, t, "text-cursor-input", "This document has no form fields."); return None; } - let rank = |name: &str| form.iter().find(|f| f.name == name).and_then(|f| f.widgets.iter().map(|w| w.tab).min()).unwrap_or(usize::MAX); - let mut ordered: Vec<_> = info.fields.iter().collect(); - ordered.sort_by_key(|f| (f.page, rank(&f.name))); + let rows = field_rows(&info.fields, form); if preparing { ui.label(egui::RichText::new(tl!("Tab order: move a field with its arrows.")).small().color(t.text_muted)); ui.label(egui::RichText::new(tl!("Shift- or Ctrl/Command-click to select several fields.")).small().color(t.text_muted)); @@ -1255,10 +1253,7 @@ fn fields( } }); } - let mut pages: Vec> = info.fields.iter().map(|f| f.page).collect(); - pages.sort(); - pages.dedup(); - for p in pages { + for (p, page_rows) in rows { // A field's /P can name a page the document doesn't have (#1307); it reads as unplaced. let label = p .and_then(|p| info.pages.get(p)) @@ -1266,11 +1261,7 @@ fn fields( .unwrap_or_else(|| tl!("Unplaced").to_string()); ui.add_space(4.0); ui.label(egui::RichText::new(label).font(theme::semibold(12.5)).color(t.text_muted)); - for f in ordered.iter().copied().filter(|f| f.page == p) { - let key = form - .iter() - .find(|field| field.name == f.name) - .and_then(|field| field.widgets.iter().position(|w| w.page == f.page).map(|wi| (field.name.clone(), wi))); + for (f, key) in page_rows { let selected = key.as_ref().is_some_and(|key| selection.selected.as_ref() == Some(key) || selection.also.contains(key)); let icon = match f.kind { FieldKind::Text => "text-cursor-input", @@ -1284,28 +1275,25 @@ fn fields( }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &f.name)); - let press = theme::Press::track(ui, &resp); - press.wash(ui, rect, 6, selected); - let body = rect.translate(press.offset()); - icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, Color32::from_rgb(0x8E, 0x4E, 0xE6)); - let value = - f.value.as_ref().map(|v| if v.chars().count() > 18 { format!("{}…", v.chars().take(17).collect::()) } else { v.clone() }); - let value_font = theme::regular(11.5); - let value_w = value.as_ref().map(|v| crate::widgets::text_width(ui, v, &value_font)).unwrap_or(0.0); - let arrows = if preparing && f.page.is_some() { 56.0 } else { 0.0 }; - let max_w = (rect.width() - 32.0 - value_w - arrows - 16.0).max(0.0); - crate::widgets::paint_left(ui, body.left_center() + vec2(32.0, 0.0), &f.name, theme::regular(13.0), t.text, max_w); - if let Some(v) = value { - let x = if arrows > 0.0 { 64.0 } else { 8.0 }; - ui.painter().text(body.right_center() - vec2(x, 0.0), Align2::RIGHT_CENTER, v, value_font, t.text_faint); - } - let kind = format!("{:?}", f.kind); - let mut tip = crate::i18n::fmt(tl!("{kind} field"), &[("kind", tl!(&kind))]); - if let Some(tt) = &f.tooltip { - tip.push_str(&format!(" — {tt}")); - } - if f.has_actions { - tip.push_str(&format!("\n{}", tl!("Has JavaScript actions (run in M6)"))); + // A row scrolled out of view still takes clicks and keyboard focus, but isn't drawn: + // laying out every row of a large form each frame took a tenth of a second a frame. + let visible = ui.is_rect_visible(rect); + if visible { + let press = theme::Press::track(ui, &resp); + press.wash(ui, rect, 6, selected); + let body = rect.translate(press.offset()); + icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, Color32::from_rgb(0x8E, 0x4E, 0xE6)); + let value = + f.value.as_ref().map(|v| if v.chars().count() > 18 { format!("{}…", v.chars().take(17).collect::()) } else { v.clone() }); + let value_font = theme::regular(11.5); + let value_w = value.as_ref().map(|v| crate::widgets::text_width(ui, v, &value_font)).unwrap_or(0.0); + let arrows = if preparing && f.page.is_some() { 56.0 } else { 0.0 }; + let max_w = (rect.width() - 32.0 - value_w - arrows - 16.0).max(0.0); + crate::widgets::paint_left(ui, body.left_center() + vec2(32.0, 0.0), &f.name, theme::regular(13.0), t.text, max_w); + if let Some(v) = value { + let x = if arrows > 0.0 { 64.0 } else { 8.0 }; + ui.painter().text(body.right_center() - vec2(x, 0.0), Align2::RIGHT_CENTER, v, value_font, t.text_faint); + } } if preparing && f.page.is_some() { let up = Rect::from_center_size(rect.right_center() - vec2(44.0, 0.0), vec2(22.0, 22.0)); @@ -1315,9 +1303,11 @@ fn fields( { let b = ui.interact(r, ui.id().with((&f.name, earlier)), Sense::click()); b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{tip}: {}", f.name))); - let bpress = theme::Press::track(ui, &b); - bpress.wash(ui, r, 4, false); - icons::paint(ui, r.shrink(3.0).translate(bpress.offset()), icon, 15.0, t.icon); + if visible { + let bpress = theme::Press::track(ui, &b); + bpress.wash(ui, r, 4, false); + icons::paint(ui, r.shrink(3.0).translate(bpress.offset()), icon, 15.0, t.icon); + } if theme::hand(b).on_hover_text(tip).clicked() { *edit = Some(pdfcraft_engine::Edit::MoveInTabOrder { name: f.name.clone(), earlier }); } @@ -1336,7 +1326,10 @@ fn fields( { selection.select(key, ui.input(|i| i.modifiers.shift || i.modifiers.command)); } - if theme::hand(resp).on_hover_text(tip).clicked() + let resp = theme::hand(resp); + // Only a row in view can be hovered: its tip isn't built for the others. + let resp = if visible { resp.on_hover_text(field_tip(f)) } else { resp }; + if resp.clicked() && let (Some(p), Some(r)) = (f.page, f.rect) { *nav = Some(Nav::Flash(p, r)); @@ -1346,6 +1339,46 @@ fn fields( properties } +/// A Fields panel row's tooltip: the field's kind, its own tooltip and whether it has scripts. +fn field_tip(f: &pdfcraft_render::Field) -> String { + let kind = format!("{:?}", f.kind); + let mut tip = crate::i18n::fmt(tl!("{kind} field"), &[("kind", tl!(&kind))]); + if let Some(tt) = &f.tooltip { + tip.push_str(&format!(" — {tt}")); + } + if f.has_actions { + tip.push_str(&format!("\n{}", tl!("Has JavaScript actions (run in M6)"))); + } + tip +} + +/// One page's rows of the Fields panel: each field, with the form field and widget a click on +/// it selects (`None` when the form has no widget of it on that page). +type FieldRows<'a> = Vec<(&'a pdfcraft_render::Field, Option<(String, usize)>)>; + +/// The Fields panel's rows: by page ("Unplaced" first), each page's in tab order. A row is paired +/// with the first form field of its name, as the engine pairs them: one map, built once a frame. +/// Searching the form for each row, and again inside the sort, was O(fields²) every frame. +fn field_rows<'a>(fields: &'a [pdfcraft_render::Field], form: &[pdfcraft_engine::FormField]) -> Vec<(Option, FieldRows<'a>)> { + let mut by_name: std::collections::HashMap<&str, &pdfcraft_engine::FormField> = std::collections::HashMap::with_capacity(form.len()); + for field in form { + by_name.entry(field.name.as_str()).or_insert(field); + } + let rank = |name: &str| by_name.get(name).and_then(|f| f.widgets.iter().map(|w| w.tab).min()).unwrap_or(usize::MAX); + let mut ordered: Vec<&pdfcraft_render::Field> = fields.iter().collect(); + ordered.sort_by_cached_key(|f| (f.page, rank(&f.name))); + let mut rows: Vec<(Option, FieldRows<'a>)> = Vec::new(); + for f in ordered { + let key = + by_name.get(f.name.as_str()).and_then(|field| field.widgets.iter().position(|w| w.page == f.page).map(|wi| (field.name.clone(), wi))); + match rows.last_mut() { + Some((page, page_rows)) if *page == f.page => page_rows.push((f, key)), + _ => rows.push((f.page, vec![(f, key)])), + } + } + rows +} + pub fn human_size(n: usize) -> String { match n { n if n >= 1 << 20 => format!("{:.1} MB", n as f64 / (1u64 << 20) as f64), @@ -1388,6 +1421,115 @@ mod page_moves { } } +#[cfg(test)] +mod field_rows { + use super::field_rows; + use std::sync::Arc; + + /// A PDF of `objects` (numbered from 1; object 1 the catalog) with a valid xref table. + fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes()); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes()); + for o in offsets { + out.extend(format!("{o:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes()); + out + } + + /// The fields the engine reads from `bytes`: the inspector's list and the form. + fn open(bytes: Vec) -> (Vec, Arc>) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("form.pdf", None, Arc::new(bytes), None).expect("opens"); + let doc = session.get(id).expect("open"); + (doc.info.fields.clone(), doc.form.clone()) + } + + /// Rows go by page, the unplaced first, each page's in tab order (here its annotation order, + /// not the `/Fields` order), and a row selects the first form field of its name. + #[test] + fn rows_follow_pages_and_tab_order() { + let widget = |name: &str, page: Option| match page { + Some(p) => format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [10 10 90 30] /P {} 0 R >>", 3 + p), + None => format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [10 10 90 30] >>"), + }; + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 6 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R 5 0 R] >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [7 0 R 8 0 R 9 0 R] >>".into(), + widget("a1", Some(0)), + widget("a2", Some(0)), + widget("b", Some(1)), + widget("twin", Some(1)), + widget("twin", Some(1)), + widget("loose", None), + ]); + let (fields, form) = open(bytes); + // A page's rows as (field name, the form field and widget a click selects). + type Named<'a> = Vec<(&'a str, Option<(String, usize)>)>; + let rows: Vec<(Option, Named)> = field_rows(&fields, &form) + .into_iter() + .map(|(page, rows)| (page, rows.into_iter().map(|(f, key)| (f.name.as_str(), key)).collect())) + .collect(); + let key = |name: &str| Some((name.to_string(), 0)); + assert_eq!( + rows, + [ + (None, vec![("loose", key("loose"))]), + (Some(0), vec![("a2", key("a2")), ("a1", key("a1"))]), + (Some(1), vec![("b", key("b")), ("twin", key("twin")), ("twin", key("twin"))]), + ] + ); + } + + /// The panel's rows for a large form take milliseconds: looking each row's field up in the + /// form, there and inside the sort, was O(fields²) every frame. + #[test] + fn rows_for_a_large_form_are_quick() { + const PAGES: usize = 150; + const PER_PAGE: usize = 100; + let mut objects = vec![String::new(), String::new()]; + let page = |p: usize| 3 + p; + let field = |p: usize, k: usize| 3 + PAGES + p * PER_PAGE + k; + let mut all = Vec::new(); + for p in 0..PAGES { + let annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); + all.extend(annots.iter().cloned()); + objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >>", annots.join(" "))); + } + for p in 0..PAGES { + for k in 0..PER_PAGE { + objects.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (field{p}_{k}) /Rect [10 10 90 30] /P {} 0 R >>", page(p))); + } + } + // `/Fields` in no particular order, so the rows must be sorted. + let mut seed = 12345u64; + for i in (1..all.len()).rev() { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + all.swap(i, (seed >> 33) as usize % (i + 1)); + } + objects[0] = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{}] >> >>", all.join(" ")); + let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", page(p))).collect(); + objects[1] = format!("<< /Type /Pages /Kids [{}] /Count {PAGES} >>", kids.join(" ")); + let (fields, form) = open(pdf(&objects)); + assert_eq!(fields.len(), PAGES * PER_PAGE); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let rows = field_rows(&fields, &form); + let _ = tx.send((rows.len(), rows.iter().map(|(_, r)| r.len()).sum::())); + }); + let counted = rx.recv_timeout(std::time::Duration::from_secs(3)).expect("the Fields panel's rows must not take seconds a frame"); + assert_eq!(counted, (PAGES, PAGES * PER_PAGE)); + } +} + #[cfg(test)] mod label_column { use super::ellipsized_prefix; diff --git a/docs/roadmap.md b/docs/roadmap.md index bccae8026a..22a24b81c7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -166,6 +167,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st |---|---|---| | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | +| 2026-10-13 | minor | M2/M6: images with at most 16 bits of samples a pixel (8-bit Separation/DeviceN, 1-4-bit gray or indexed) convert once per pixel value (hayro-interpret patch 28), so spot-colour form backgrounds draw in milliseconds instead of seconds; widgets without `/P` are placed in one pass; the Fields panel no longer searches the form per row and draws only the rows in view. Estimates unchanged. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | diff --git a/vendor/README.md b/vendor/README.md index c433193f3a..33248d09d4 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,7 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi` +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours` | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled` | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 68d97d5555..d3654871a7 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -667,23 +667,38 @@ fn decode_raster( ctx.bits_per_component, )?; - let mut f32_data = apply_decode_array( + // PdfCraft patch (28): an image with few possible pixel values converts each of them once. + let mut rgb_data = if let Some(rgb) = rgb_via_lookup( &components, - &ctx.color_space, - ctx.bits_per_component, - &ctx.decode_arr, - )?; - - fix_image_length(&mut f32_data, ctx.width, &mut height, 0.0, &ctx.color_space)?; - - let mut rgb_data = get_rgb_data( - &f32_data, ctx.width, height, ctx.scale_factors, &ctx.color_space, + ctx.bits_per_component, + &ctx.decode_arr, obj.interpolate, - ); + ) { + height = rgb.height; + Some(rgb) + } else { + let mut f32_data = apply_decode_array( + &components, + &ctx.color_space, + ctx.bits_per_component, + &ctx.decode_arr, + )?; + + fix_image_length(&mut f32_data, ctx.width, &mut height, 0.0, &ctx.color_space)?; + + get_rgb_data( + &f32_data, + ctx.width, + height, + ctx.scale_factors, + &ctx.color_space, + obj.interpolate, + ) + }; if let Some(transfer_function) = &obj.transfer_function && let Some(rgb_data) = &mut rgb_data @@ -1034,27 +1049,136 @@ fn apply_decode_array( bits_per_component: u8, decode: &[(f32, f32)], ) -> Option> { - let interpolate = |n: f32, d_min: f32, d_max: f32| { - interpolate( - n, - 0.0, - 2.0_f32.powi(bits_per_component as i32) - 1.0, - d_min, - d_max, - ) - }; - let mut decoded_arr = vec![]; for pixel in components.chunks(color_space.num_components() as usize) { - for (component, (d_min, d_max)) in pixel.iter().zip(decode) { - decoded_arr.push(interpolate(*component as f32, *d_min, *d_max)); + for (component, range) in pixel.iter().zip(decode) { + decoded_arr.push(decode_sample(*component, bits_per_component, *range)); } } Some(decoded_arr) } +/// One sample mapped through its `/Decode` range (ISO 32000-2 §8.9.5.2). PdfCraft patch (28): +/// shared by [`apply_decode_array`] and [`rgb_via_lookup`], so both decode alike. +fn decode_sample(sample: u16, bits_per_component: u8, (d_min, d_max): (f32, f32)) -> f32 { + interpolate( + sample as f32, + 0.0, + 2.0_f32.powi(bits_per_component as i32) - 1.0, + d_min, + d_max, + ) +} + +/// PdfCraft patch (28): the most bits a pixel's samples may hold together for +/// [`rgb_via_lookup`] (a table of 65,536 colours). +const MAX_LOOKUP_BITS: usize = 16; + +/// PdfCraft patch (28): the RGB of an image whose pixels hold at most [`MAX_LOOKUP_BITS`] bits +/// of samples, converted once per possible pixel value rather than once per pixel, at the +/// height `fix_image_length` gives. `None` when the table would not pay off (the image has no +/// more pixels than it has entries) or does not apply; the caller then converts every pixel. +/// +/// The colour of a pixel depends only on its own samples, so the result is the per-pixel one, +/// except where a tint transform gives the wrong number of components. The per-pixel batch then +/// shifts the colours after that pixel. The table converts each entry on its own +/// ([`convert_each`]), so a wrong-sized entry changes only itself, and a value the image never +/// uses cannot change the colour of one it does. +/// A Separation or DeviceN tint transform is a PDF function, often a PostScript calculator +/// program: run once per pixel, an 8-bit spot-colour background of 3304 × 4105 took four +/// seconds and 430 MB to draw; looked up from 256 entries it takes milliseconds. +#[allow(clippy::too_many_arguments)] +fn rgb_via_lookup( + components: &[u16], + width: u32, + height: u32, + scale_factors: (f32, f32), + cs: &ColorSpace, + bits_per_component: u8, + decode: &[(f32, f32)], + interpolate: bool, +) -> Option { + let n = cs.num_components() as usize; + let bpc = usize::from(bits_per_component); + let bits = bpc.checked_mul(n)?; + // `apply_decode_array` decodes only the samples `/Decode` has a range for: leave a short + // array to it, and every sample size it doesn't read. + if n == 0 || bits == 0 || bits > MAX_LOOKUP_BITS || bpc > 16 || decode.len() < n { + return None; + } + // The length rules of `fix_image_length`: extra samples are dropped; missing ones shorten + // the image to the rows begun, the last row padded with decoded zeros. + let row_len = usize::try_from(width).ok()?.checked_mul(n)?; + let wanted = row_len.checked_mul(usize::try_from(height).ok()?)?; + let (height, used) = if wanted <= components.len() { + (height, wanted) + } else { + (u32::try_from(components.len().div_ceil(row_len)).ok()?, components.len()) + }; + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + let total = pixels.checked_mul(n)?; + let entries = 1usize << bits; + if pixels <= entries { + return None; + } + + // Every possible pixel value, its samples in order, decoded as `apply_decode_array` would. + let mask = (1usize << bpc) - 1; + let mut table_input = Vec::with_capacity(entries * n); + for code in 0..entries { + for (k, range) in decode.iter().take(n).enumerate() { + let sample = ((code >> (bpc * (n - 1 - k))) & mask) as u16; + table_input.push(decode_sample(sample, bits_per_component, *range)); + } + } + let mut table = vec![0; entries * 3]; + convert_each(cs, &table_input, n, &mut table); + + let mut data = vec![0; pixels.checked_mul(3)?]; + let whole = used / n; + let samples = components.get(..whole * n)?; + for (pixel, rgb) in samples.chunks_exact(n).zip(data.chunks_exact_mut(3)) { + let code = pixel.iter().fold(0usize, |code, s| (code << bpc) | (usize::from(*s) & mask)); + if let Some(colour) = table.get(code * 3..code * 3 + 3) { + rgb.copy_from_slice(colour); + } + } + // The pixels past the samples present (at most a row): converted as before, padding and all. + if whole < pixels { + let tail: Vec = (whole * n..total) + .map(|i| match (components.get(i), decode.get(i % n)) { + (Some(s), Some(range)) if i < used => decode_sample(*s, bits_per_component, *range), + _ => 0.0, + }) + .collect(); + let _ = cs.convert_f32(&tail, data.get_mut(whole * 3..)?, false); + } + + Some(RgbData { + data, + width, + height, + interpolate, + scale_factors, + }) +} + +/// PdfCraft patch (28): `input`, `n` decoded samples a pixel, converted into `output` (RGB) one +/// pixel at a time. A Separation or DeviceN tint transform may give more or fewer components +/// than its alternate space takes (a Type 4 program's count is only warned about): converted as +/// one batch, such a result shifts every pixel after it; converted alone, it affects only its +/// own. A failed conversion leaves black, as in `get_rgb_data`. +fn convert_each(cs: &ColorSpace, input: &[f32], n: usize, output: &mut [u8]) { + if n == 0 { + return; + } + for (pixel, rgb) in input.chunks_exact(n).zip(output.chunks_exact_mut(3)) { + let _ = cs.convert_f32(pixel, rgb, false); + } +} + /// PdfCraft patch: whether an image or mask of `width` × `height` may be decoded (at most /// 2^28 pixels, the renderer's `MAX_IMAGE_PIXELS`). pub fn image_size_ok(width: u32, height: u32) -> bool { From b558ef74473d9fe3ee06ce13e813950edf17b8aa Mon Sep 17 00:00:00 2001 From: null 00000111 <287216888+s17sh70x2jfl031ag@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:20:41 -0700 Subject: [PATCH 027/207] i18n(pt-br): fix 6 Brazilian Portuguese strings (#1694) * i18n(pt-br): fix 5 Brazilian Portuguese strings A subject-verb agreement slip, a hyphenated colour, and three terms that disagreed with the catalog's own wording. * i18n(pt-br): call the comb option "Caixas", as its warning does Keeps the warning's "As caixas de caracteres" and renames the option from the literal "Pente" to match it. --- crates/ui-egui/src/i18n/pt-br.tsv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 261a86810e..4513451624 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -264,7 +264,7 @@ characters caracteres Field is used for file selection O campo é usado para seleção de arquivo Check spelling Verificar ortografia - Comb of Pente de + Comb of Caixas para Export Value: {on} Valor de exportação: {on} Check box is checked by default A caixa de seleção fica marcada por padrão Button is checked by default O botão fica selecionado por padrão @@ -1192,7 +1192,7 @@ Pink Rosa Purple Roxo Blue Azul - Light blue Azul claro + Light blue Azul-claro Green Verde Gray Cinza Black Preto @@ -1913,7 +1913,7 @@ the image is empty a imagem está vazia The operation failed. A operação falhou. the password is incorrect a senha está incorreta - The title in the metadata and the document information differ O título nos metadados e nas informações do documento são diferentes + The title in the metadata and the document information differ Os títulos nos metadados e nas informações do documento são diferentes the title must not be empty o título não deve estar vazio there are no pages to print não há páginas para imprimir this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) este documento está assinado: regravá-lo invalidaria suas assinaturas (salve de forma incremental) @@ -2035,7 +2035,7 @@ {name} can't be combined: {why} {name} não pode ser combinado: {why} Selected pages Páginas selecionadas No pages selected Nenhuma página selecionada - Created a PDF from {n} files; left out: {names} Criado um PDF de {n} arquivos; deixando de fora: {names} + Created a PDF from {n} files; left out: {names} PDF criado a partir de {n} arquivos; deixados de fora: {names} Created a PDF from the first {n} files Criado um PDF dos primeiros {n} arquivos Created a PDF from {n} files Criado um PDF de {n} arquivos Folders Pastas @@ -2108,7 +2108,7 @@ action wizard Start Iniciar Choose a folder on this computer to pin Escolha uma pasta deste computador para fixar Commits Commits Company Empresa - Contributors Colaboradores + Contributors Contribuidores Couldn't open the printer's preferences: {e} Não foi possível abrir as preferências da impressora: {e} Couldn't optimize the file Não foi possível otimizar o arquivo Couldn't show the file picker. Please try again. Não foi possível mostrar o seletor de arquivos. Tente novamente. @@ -2199,7 +2199,7 @@ comment menu Edit Editar Character limit: Limite de caracteres: Character limit 0 means no limit. A comb of characters needs a limit. O limite de caracteres 0 significa sem limite. As caixas de caracteres precisam de um limite. To change locked fields, also set Locked to No. Para alterar campos bloqueados, defina também “Bloqueado” como “Não”. - Comb of characters Pente de caracteres + Comb of characters Caixas de caracteres The document changed while you were editing field properties. O documento mudou enquanto você editava as propriedades dos campos. The document is no longer open. O documento não está mais aberto. The field properties dialog is no longer open. A caixa de propriedades dos campos não está mais aberta. From 089e13c0749ea9229c33ac780db75390dfde70c7 Mon Sep 17 00:00:00 2001 From: manchitmr <74224148+manchitmr@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:50:51 +0530 Subject: [PATCH 028/207] Sinhala and Tamil interface: draw with an installed font (part of #1688) (#1706) Sinhala and Tamil file names showed as boxes on the home screen, in Recent and in document tabs: no embedded face covers either script, and the installed-font fallback did not look for one. Add sinhala_fallback() and tamil_fallback(), each probing for its script's letter ka, and end every interface family with them: - macOS: Sinhala Sangam MN / Sinhala MN, Tamil Sangam MN / Tamil MN - Windows: Nirmala UI, then Iskoola Pota (Sinhala) or Latha (Tamil) - Linux: Noto Sans Sinhala / Noto Sans Tamil, Lohit Tamil The faces are read at runtime, never embedded or shipped, and PDFCRAFT_SYSTEM_FONTS=0 still turns them off. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/system_fonts.rs | 77 ++++++++++++++++++++++++++++-- crates/ui-egui/src/theme.rs | 11 ++++- crates/ui-egui/tests/fonts.rs | 26 ++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 3083fc5f05..4712c3149f 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -3,8 +3,8 @@ //! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one //! only draws characters none of them has, such as an Arabic file name in a build without //! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). A -//! second and a third installed face, [`devanagari_fallback`] and [`telugu_fallback`], draw Hindi -//! and Telugu when one is installed. It is +//! few more installed faces, [`devanagari_fallback`], [`telugu_fallback`], [`sinhala_fallback`] +//! and [`tamil_fallback`], draw Hindi, Telugu, Sinhala and Tamil when one is installed. It is //! read at runtime and never embedded or shipped (AGENTS.md §1.4), and `PDFCRAFT_SYSTEM_FONTS=0` //! turns it off (published screenshots do). @@ -25,6 +25,10 @@ const PROBE: char = '\u{0627}'; const DEVANAGARI_PROBE: char = '\u{0915}'; /// Telugu letter ka: the face must have it to cover Telugu. const TELUGU_PROBE: char = '\u{0C15}'; +/// Sinhala letter ka (ක): the face must have it to cover Sinhala. +const SINHALA_PROBE: char = '\u{0D9A}'; +/// Tamil letter ka (க): the face must have it to cover Tamil. +const TAMIL_PROBE: char = '\u{0B95}'; /// 欢 (U+6B22): Simplified only, absent from the Japanese craft-fonts faces. const HANS_PROBE: char = '\u{6B22}'; /// 說 (U+8AAA): Traditional only, absent from the Japanese craft-fonts faces. @@ -82,6 +86,20 @@ pub fn telugu_fallback() -> Option> { CACHE.get_or_init(|| script_fallback(&telugu_candidates(), TELUGU_PROBE)).clone() } +/// The installed fallback face for Sinhala, read once. `None` when it is turned off or no +/// candidate is installed; the interface then draws what the embedded faces can. +pub fn sinhala_fallback() -> Option> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| script_fallback(&sinhala_candidates(), SINHALA_PROBE)).clone() +} + +/// The installed fallback face for Tamil, read once. `None` when it is turned off or no +/// candidate is installed; the interface then draws what the embedded faces can. +pub fn tamil_fallback() -> Option> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| script_fallback(&tamil_candidates(), TAMIL_PROBE)).clone() +} + fn script_fallback(candidates: &[PathBuf], probe: char) -> Option> { if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { return None; @@ -155,6 +173,57 @@ fn telugu_candidates() -> Vec { } } +/// Well-known locations of Sinhala faces, best first. Nirmala UI has Sinhala from Windows 10; +/// Iskoola Pota is the older Windows Sinhala face. +fn sinhala_candidates() -> Vec { + if cfg!(windows) { + ["Nirmala.ttc", "Nirmala.ttf", "iskpota.ttf"].iter().map(|f| windows_fonts().join(f)).collect() + } else if cfg!(target_os = "macos") { + ["/System/Library/Fonts/Supplemental/Sinhala Sangam MN.ttc", "/System/Library/Fonts/Supplemental/Sinhala MN.ttc"] + .iter() + .map(PathBuf::from) + .collect() + } else { + unix_fonts(&[ + "google-noto/NotoSansSinhalaUI-Regular.ttf", + "google-noto/NotoSansSinhala-Regular.ttf", + "google-noto-vf/NotoSansSinhala[wght].ttf", + "truetype/noto/NotoSansSinhala-Regular.ttf", + "truetype/noto/NotoSansSinhalaUI-Regular.ttf", + "noto/NotoSansSinhala-Regular.ttf", + "noto/NotoSansSinhalaUI-Regular.ttf", + ]) + } +} + +/// Well-known locations of Tamil faces, best first. Latha is the older Windows Tamil face. +fn tamil_candidates() -> Vec { + if cfg!(windows) { + ["Nirmala.ttc", "Nirmala.ttf", "latha.ttf"].iter().map(|f| windows_fonts().join(f)).collect() + } else if cfg!(target_os = "macos") { + [ + "/System/Library/Fonts/Supplemental/Tamil Sangam MN.ttc", + "/System/Library/Fonts/Supplemental/Tamil MN.ttc", + "/Library/Fonts/Arial Unicode.ttf", + ] + .iter() + .map(PathBuf::from) + .collect() + } else { + unix_fonts(&[ + "google-noto/NotoSansTamilUI-Regular.ttf", + "google-noto/NotoSansTamil-Regular.ttf", + "google-noto-vf/NotoSansTamil[wght].ttf", + "truetype/noto/NotoSansTamil-Regular.ttf", + "truetype/noto/NotoSansTamilUI-Regular.ttf", + "noto/NotoSansTamil-Regular.ttf", + "noto/NotoSansTamilUI-Regular.ttf", + "lohit-tamil-fonts/Lohit-Tamil.ttf", + "truetype/lohit-tamil/Lohit-Tamil.ttf", + ]) + } +} + /// Well-known locations of faces with broad script coverage, best first. fn candidates(need: Need) -> Vec { match need { @@ -292,11 +361,13 @@ mod tests { assert!(script_fallback(&candidates, 'A').is_some()); assert!(script_fallback(&candidates, TELUGU_PROBE).is_none()); assert!(script_fallback(&candidates, DEVANAGARI_PROBE).is_none()); + assert!(script_fallback(&candidates, SINHALA_PROBE).is_none()); + assert!(script_fallback(&candidates, TAMIL_PROBE).is_none()); } #[test] fn script_candidates_are_absolute_font_files() { - for list in [devanagari_candidates(), telugu_candidates()] { + for list in [devanagari_candidates(), telugu_candidates(), sinhala_candidates(), tamil_candidates()] { assert!(!list.is_empty()); assert!(list.iter().all(|p| p.is_absolute() || cfg!(windows)), "{list:?}"); assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc")), "{list:?}"); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9f2bb90090..9a0620b167 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -220,6 +220,10 @@ pub const SYSTEM_FALLBACK: &str = "system-fallback"; pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-fallback"; /// The name of the installed Telugu face [`installed_font_definitions`] may add. pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback"; +/// The name of the installed Sinhala face [`installed_font_definitions`] may add. +pub const SYSTEM_SINHALA_FALLBACK: &str = "system-sinhala-fallback"; +/// The name of the installed Tamil face [`installed_font_definitions`] may add. +pub const SYSTEM_TAMIL_FALLBACK: &str = "system-tamil-fallback"; /// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face /// already installed on this machine. It only draws characters no embedded face has. @@ -232,8 +236,9 @@ pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback"; /// - Otherwise it is the last fallback of every family (an Arabic file name in a build without /// craft-fonts). /// -/// An installed Devanagari face and an installed Telugu face, when there are, end every family -/// just before that one, so Hindi and Telugu text draw in a build without such faces (#1531). +/// Installed Devanagari, Telugu, Sinhala and Tamil faces, when there are, end every family just +/// before that one, so Hindi, Telugu (#1531), Sinhala and Tamil (#1688) text, such as a file +/// name, draws in a build without such faces. /// /// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out. pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { @@ -246,6 +251,8 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { for (name, data) in [ (SYSTEM_DEVANAGARI_FALLBACK, crate::system_fonts::devanagari_fallback()), (SYSTEM_TELUGU_FALLBACK, crate::system_fonts::telugu_fallback()), + (SYSTEM_SINHALA_FALLBACK, crate::system_fonts::sinhala_fallback()), + (SYSTEM_TAMIL_FALLBACK, crate::system_fonts::tamil_fallback()), ] { let Some(data) = data else { continue }; fonts.font_data.insert(name.to_owned(), data); diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 63a31d4cfc..3ec478255f 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -193,6 +193,32 @@ fn system_fallback_covers_telugu() { assert!(layout_widths(&mut fonts, TELUGU).iter().all(|w| w.is_finite() && *w > 0.0)); } +/// On a machine with installed Sinhala and Tamil fonts, the installed definitions include them +/// and Sinhala and Tamil text, such as a file name, has glyphs instead of boxes (#1688). macOS +/// always ships Sinhala Sangam MN and Tamil Sangam MN, so there the test never skips. +#[test] +fn system_fallback_covers_sinhala_and_tamil() { + for (name, text) in [(theme::SYSTEM_SINHALA_FALLBACK, "ශ්‍රී ලංකා ගොනුව"), (theme::SYSTEM_TAMIL_FALLBACK, "தமிழ் கோப்பு")] + { + assert!(!theme::font_definitions().font_data.contains_key(name)); + let defs = theme::installed_font_definitions(theme::CjkPreference::Default); + let off = std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0"); + if !defs.font_data.contains_key(name) && (off || !cfg!(target_os = "macos")) { + eprintln!("skipping {name}: no installed fallback font (or PDFCRAFT_SYSTEM_FONTS=0)"); + assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == name))); + continue; + } + for (family, stack) in &defs.families { + assert_eq!(stack.iter().filter(|n| *n == name).count(), 1, "{family:?}: {stack:?}"); + } + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + assert!(fonts.has_glyphs(&id, text), "{id:?} lacks {text}"); + } + assert!(layout_widths(&mut fonts, text).iter().all(|w| w.is_finite() && *w > 0.0)); + } +} + /// The Han characters of a catalog's translations. fn catalog_han(catalog: &str) -> String { let mut han: Vec = catalog From 00957b9f219e4c20d0bfdcecdf9a6ea956e46f84 Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:21:01 +0200 Subject: [PATCH 029/207] M5: Fill & Sign text reads across on rotated pages (#1710) * M5: Fill & Sign text reads across on rotated pages Typed text, dates and text boxes ignored the page's /Rotate, so on a page stored sideways (common for scanned forms) the text came out sideways. The box now follows the displayed axes, the annotation records the turn in /PCTextRotate and its appearance is turned back with a form /Matrix, as stamps and image signatures already do. Callouts keep user-space axes. The headless fill_sign_add tool gets the same layout. generated with Claude Code * chore: credit alexanderalber in the About window generated with Claude Code --- contributors/people.toml | 3 ++ crates/annot/src/appearance.rs | 25 ++++++++++++++- crates/annot/src/lib.rs | 7 ++++ crates/automation/src/comments.rs | 3 +- crates/engine/src/lib.rs | 13 ++++++++ crates/engine/src/tests.rs | 53 +++++++++++++++++++++++++++++++ crates/ui-egui/src/comments.rs | 25 +++++++++++---- crates/ui-egui/src/editing.rs | 3 +- crates/ui-egui/src/fill_sign.rs | 21 +++++++----- parity/acrobat-features.toml | 4 +-- 10 files changed, 138 insertions(+), 19 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index aa8dd7a158..eb0e008d46 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -129,3 +129,6 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.alexanderalber] +real_name = "Alexander Alber" diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index a5394038d0..0f0706428f 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -269,6 +269,12 @@ fn stamp_turn(d: &Dict) -> i64 { d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by +/// (`/PCTextRotate`, written when it is added to a turned page). +fn text_turn(d: &Dict) -> i64 { + d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 +} + /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The /// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's @@ -666,6 +672,19 @@ pub fn build(d: &Dict) -> Option { let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; // A callout: the leader line (arrowhead at its first point), and the text box inside // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; let full = rect; let mut rect = rect; if d.contains(b"CL") { @@ -751,7 +770,11 @@ pub fn build(d: &Dict) -> Option { let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), Object::Dict(font)); res.set(b"Font".to_vec(), Object::Dict(fonts)); - return Some(form(full, &out, res)); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + return Some(stream); } _ => return None, } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index a25b35c3b9..5621f4638b 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1206,6 +1206,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); rich_text(&mut d); + // The text is laid out for the page as displayed, like a stamp's label. + if !matches!(new.shape, Shape::Callout { .. }) { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); + } + } } _ => {} } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index d98c48723e..10036d7e61 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -511,7 +511,8 @@ impl Automation { let text_at = |t: &str| { let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); let h = size * 1.2 + 6.0; - Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size } + let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); + Shape::Typewriter { rect, font_size: size } }; let (shape, contents) = match kind { "text" => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 19f18fea95..f170a07d10 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,6 +107,19 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i6 let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); Some(Shape::TypedSignature { rect, contours }) } +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + /// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types /// it. `None` for text with no outlines. pub fn typed_signature_graphic(text: &str) -> Option { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 99594cde25..133587202d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -196,6 +196,59 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { } } +/// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are +/// often stored sideways with `/Rotate`), including after save. +#[test] +fn typed_text_reads_across_as_displayed_on_rotated_pages() { + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + // Displayed top-left of the text at (40, 60): a wide first line, then one letter below + // its start. + let at = info.view_to_user(40.0, 60.0); + let at = [f64::from(at[0]), f64::from(at[1])]; + let rect = text_box_at(degrees, at, 120.0, 2.0 * 12.0 * 1.2 + 6.0).unwrap(); + let shape = Shape::Typewriter { rect, font_size: 12.0 }; + let style = Style::default_for(&shape); + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style, + shape, + contents: "MMMMMMMM +I" + .into(), + author: "Test".into(), + }), + ) + .unwrap(); + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + let dark = |x: u32, y: u32| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128); + let ink: Vec<(u32, u32)> = + (0..rendered.height).flat_map(|y| (0..rendered.width).map(move |x| (x, y))).filter(|&(x, y)| dark(x, y)).collect(); + assert!(!ink.is_empty(), "rotation {degrees}: no text drawn"); + let (x0, x1) = (ink.iter().map(|p| p.0).min().unwrap(), ink.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (ink.iter().map(|p| p.1).min().unwrap(), ink.iter().map(|p| p.1).max().unwrap()); + // Rendered at scale 2: the text starts at the clicked point and runs to the right. + assert!((x1 - x0) > 2 * (y1 - y0), "rotation {degrees}: ink {x0}..{x1} x {y0}..{y1} should be wide, not tall"); + assert!(x0.abs_diff(80) < 16 && y0.abs_diff(120) < 16, "rotation {degrees}: text starts at ({x0}, {y0}), not near (80, 120)"); + // The lone "I" is on the second line under the start of the first, not upside down + // (above the end). + let mid = (y0 + y1) / 2; + let lower: Vec = ink.iter().filter(|p| p.1 > mid + 2).map(|p| p.0).collect(); + assert!(!lower.is_empty() && lower.iter().all(|x| *x < x0 + (x1 - x0) / 4), "rotation {degrees}: second line is not under the start"); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// A one-page PDF whose left half is red and whose right half is white, as displayed. fn half_red_page() -> Vec { let content = "1 0 0 rg 0 0 40 40 re f"; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 6f3d8ab876..67d2c1ce91 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1252,11 +1252,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, } ComposerKind::TextBox => { view.comments.tool_done = true; - Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0), font_size: 12.0 }, text)) + Some(new_comment( + &cx, + CommentTool::TextBox, + Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0, page_rotation(info, page)), font_size: 12.0 }, + text, + )) } ComposerKind::Callout { point } => { view.comments.tool_done = true; - let rect = text_box_rect(c.at, &text, 10.0); + // A callout's leader and box stay in user-space axes (its appearance isn't turned). + let rect = text_box_rect(c.at, &text, 10.0, 0); let side = if point[0] < rect[0] { rect[0] } else if point[0] > rect[2] { @@ -1361,14 +1367,21 @@ pub(crate) fn caret_snap(text: &PageText, vx: f32, vy: f32) -> Option<([f32; 2], } /// A text box sized to its text (at most 300 pt wide), hanging from its top-left corner. -pub fn text_box_rect(at: [f64; 2], text: &str, size: f64) -> [f64; 4] { +/// The box for `text` at `size` with its displayed top-left corner at `at`, on a page turned by +/// `rotation` (its `/Rotate`): on a quarter-turned page it is taller than wide in user space. +pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { use pdfcraft_engine::annot_text::{text_width, wrap}; let pad = 2.0; let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max); let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0); let lines = wrap(text, size, w - 2.0 * pad).len().max(1); let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0; - [at[0], at[1] - h, at[0] + w, at[1]] + pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) +} + +/// The `/Rotate` of `page` (0 when it is unknown). +pub(crate) fn page_rotation(info: &DocInfo, page: usize) -> i64 { + info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) } /// Delete / Escape handling for comments (only while no text field has focus). @@ -1920,10 +1933,10 @@ mod tests { #[test] fn text_boxes_fit_their_text() { - let r = text_box_rect([100.0, 500.0], "Hi", 12.0); + let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0); assert_eq!((r[0], r[3]), (100.0, 500.0)); assert!(r[2] - r[0] < 60.0); - let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0); + let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0, 0); assert_eq!(long[2], 300.0); assert!(long[3] - long[1] > 50.0); } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index dc7226ef64..19cd043bfa 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -509,7 +509,8 @@ impl PdfCraftApp { return true; } let author = self.comment_prefs.author.clone(); - let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, &author); + let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); + let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, rotation, &author); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index d1b5f0e59c..a541254d25 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -411,9 +411,10 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author: author.to_string() }) } -/// A typewriter annotation sized to its text. -pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, author: &str) -> Edit { - let rect = crate::comments::text_box_rect(at, text, size); +/// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by +/// `rotation` (its `/Rotate`). +pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit { + let rect = crate::comments::text_box_rect(at, text, size, rotation); new(page, Shape::Typewriter { rect, font_size: size }, text.to_string(), author) } @@ -633,16 +634,20 @@ pub(crate) fn page_input( if !resp.clicked() { return None; } + // Typed text starts a little above the click, as displayed. + let rotation = i64::from(p.rotation); + let top = pdfcraft_engine::displayed_offset(rotation, at, 0.0, text_size * 0.6); match tool { FillTool::Text => { // Clicking away from an open box commits it: the page sees this click before the box // sees its lost focus, so replacing the box first would drop what was typed. - let open = - view.fill_text.replace(TypeBox { page, at: [at[0], at[1] + text_size * 0.6], text: String::new(), size: text_size, focus: true }); - open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), tb.size, author)))) + let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, focus: true }); + open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { + FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author))) + }) } FillTool::Date => Some(match date_text { - Ok(text) => FillAction::Edit(Box::new(typed(page, [at[0], at[1] + text_size * 0.6], text, text_size, author))), + Ok(text) => FillAction::Edit(Box::new(typed(page, top, text, text_size, rotation, author))), Err(why) => FillAction::Refused(why.clone()), }), FillTool::Signature => match signature { @@ -719,7 +724,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed(tb.page, tb.at, tb.text.trim(), tb.size, author)); + return Some(typed(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author)); } } None diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 22ee2c66d6..bf39b83f8c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5244,8 +5244,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] -notes = "typewriter FreeText, 10 pt Helvetica" +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"] +notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet" [[feature]] id = "form.fill-sign-marks" From db5b9b436409b4d24eccfabf8963d2ed36da3a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Nh=E1=BA=ADt=20Duy?= <3275987+duydev@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:21:11 +0700 Subject: [PATCH 030/207] M14: add Vietnamese interface and date localization (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * G14: Vietnamese catalog — menus, file and shell chrome Add the Vietnamese (vi) TSV catalog header and translations for menus, File/Edit/Help chrome, clipboard and common shell labels. PhotoCraft and VectorCraft catalogs used only as translation memory when English source and context matched. ATTRIBUTION records the asset hash. Co-authored-by: Cursor * G14: Vietnamese catalog — forms, signatures and pages Translate form/Fill & Sign/certificate wording and page organize/rotate/ extract/split labels. Keep product and format names unchanged. Co-authored-by: Cursor * G14: Vietnamese catalog — comments, view, a11y and contexts Translate comments/markup, view/zoom/preferences tools, accessibility and preflight sentences, plus contextual rows (certificate, calendar, keys, scroll speed, signature pad, action wizard, @plural one-form). Co-authored-by: Cursor * G14: Vietnamese catalog — remaining dialogs and notices Complete the Vietnamese catalog to every (context, English) key in the bundled union (2,580 entries). No unapproved English UI fallbacks remain outside the documented VI_KEEP_AS_IS list. Co-authored-by: Cursor * G14: restore complete Vietnamese catalog key order Replace the domain-appended catalog with the verified 2,580-entry union in stable key order so coverage matches every bundled reference key. Co-authored-by: Cursor * G14: register Vietnamese interface and date language Add `vi` / Tiếng Việt to LANGUAGES and DATE_LANGUAGES with plural_none and rtl false. Locale tags vi, vi-VN and vi_VN.UTF-8 resolve. Calendar uses Tháng Một / tháng Một; Fill & Sign refuses non-WinAnsi month names. Coverage tests require full command, literal, key and credits coverage. Co-authored-by: Cursor * G14: document Vietnamese localization Record Vietnamese at 100% catalog coverage in localization-parity and ROADMAP, list Tiếng Việt in Preferences/docs, and note the G14 catalog in the progress log. Engine/OS details, document contents, filenames, CLI/MCP and external help remain out of scope by design. Co-authored-by: Cursor * G14: use ASCII > in Vietnamese preference paths Inter lacks U+25B8 (▸). Match other Latin catalogs and write preference paths as "Tùy chọn > …" so the glyph coverage test stays green. Co-authored-by: Cursor * G14: finalize Vietnamese terminology and current coverage Disambiguate theme Appearance (Giao diện) from object look (Diện mạo), normalize form Field to Trường, make redaction apply wording permanent, use numeric calendar months, close the GoToR/digital-ID key gap, and pin regression assertions. Refresh the vi.tsv attribution hash. Co-authored-by: Cursor * G14: align localization docs with Vietnamese coverage Recompute the twelve-language mean (≈59% → ≈68%), document 23 interface languages, and remove stale claims that Vietnamese is missing. Co-authored-by: Cursor * G14: cover Wayland title-bar strings in Vietnamese Translate Minimize/Maximize/Restore and the system title-bar preference added on upstream main, and refresh the catalog attribution hash and counts. Co-authored-by: Cursor * G14: correct current union key count in localization docs Co-authored-by: Cursor * G14: prepare Vietnamese localization for review Trim the vi.tsv header to maintainer-oriented notes, record native-speaker community review in attribution and localization docs, and refresh the weighted readiness arithmetic (localization ≈68% → ≈50%). Co-authored-by: Cursor --------- Co-authored-by: DUY Tran Nhat (SAE-KIDS) Co-authored-by: Cursor --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 12 + ROADMAP.md | 22 +- crates/engine/src/dates.rs | 59 +- crates/ui-egui/src/appearance.rs | 2 +- crates/ui-egui/src/i18n/mod.rs | 242 ++- crates/ui-egui/src/i18n/vi.tsv | 2601 ++++++++++++++++++++++++++++++ crates/ui-egui/tests/fonts.rs | 1 + docs/gaps.md | 15 +- docs/localization-parity.md | 34 +- docs/localization.md | 13 +- docs/target-app-parity.md | 18 +- 12 files changed, 2971 insertions(+), 51 deletions(-) create mode 100644 crates/ui-egui/src/i18n/vi.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3b451f246d..d35b663209 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (270) +## In this repository (271) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -28,6 +28,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 6715f7b4ce..5e17018c31 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -230,6 +230,18 @@ kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" sha256 = "2daa34aacddccdbe9862932c5c6f0a0d8bdb86b07cc385a82f0ceaf9f1c4f333" + +[[asset]] +path = "crates/ui-egui/src/i18n/vi.tsv" +title = "Vietnamese interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor" +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 = "f9b0694f684bea95e20bd33bef33fedfd92e28c62f2c140193c89e0ed7a18b97" + [[asset]] path = "assets/icons/accessibility.svg" title = "Lucide icon \"accessibility\"" diff --git a/ROADMAP.md b/ROADMAP.md index 242afd39db..284e87419c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # PdfCraft roadmap -**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -49,7 +49,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i |---|---|---| | **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | | Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | +| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) | | Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated | @@ -66,7 +66,7 @@ and 245 open issues keep it far from beta. | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| -| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | +| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | | Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog | | Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs | @@ -81,13 +81,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | +| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 | | Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 | | Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) | | AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | ## Features @@ -112,8 +112,8 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes: -[localization-parity.md](docs/localization-parity.md). +Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current +union). Detail and script notes: [localization-parity.md](docs/localization-parity.md). | Language | Code | Strings | Status | |---|---|---|---| @@ -128,10 +128,11 @@ Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script no | Japanese | `ja` | 99.1% | partial | | German | `de` | 99.3% | partial | | Korean | `ko` | 0% | none | -| Vietnamese | `vi` | 0% | none | +| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | -Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian -(94–100%) and Czech (menus only): **8 more languages**. +Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian, +Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs** +(23 interface languages including English). ## Upcoming @@ -150,6 +151,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. - **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index e26d9ec60a..c3f9a50861 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 22] = [ +pub static DATE_LANGUAGES: [DateLanguage; 23] = [ DateLanguage { code: "en", name: "English", @@ -360,6 +360,44 @@ pub static DATE_LANGUAGES: [DateLanguage; 22] = [ days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"], days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"], }, + // Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone). + // Fill & Sign still refuses any format whose final text fails the existing WinAnsi check + // (for example weekday names); `dd.mm.yyyy` remains writable. + DateLanguage { + code: "vi", + name: "Tiếng Việt", + months: [ + "Tháng 1", + "Tháng 2", + "Tháng 3", + "Tháng 4", + "Tháng 5", + "Tháng 6", + "Tháng 7", + "Tháng 8", + "Tháng 9", + "Tháng 10", + "Tháng 11", + "Tháng 12", + ], + months_with_day: Some([ + "tháng 1", + "tháng 2", + "tháng 3", + "tháng 4", + "tháng 5", + "tháng 6", + "tháng 7", + "tháng 8", + "tháng 9", + "tháng 10", + "tháng 11", + "tháng 12", + ]), + months_short: ["Thg 1", "Thg 2", "Thg 3", "Thg 4", "Thg 5", "Thg 6", "Thg 7", "Thg 8", "Thg 9", "Thg 10", "Thg 11", "Thg 12"], + days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"], + days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"], + }, ]; /// The date language for `code` (any case), or `None` when there's none. @@ -683,6 +721,25 @@ mod tests { assert_eq!(s.date_language(), None); } + #[test] + fn vietnamese_month_and_weekday_names() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_date_language(Some("vi")).unwrap(); + assert_eq!(s.date_language(), Some("vi")); + s.set_date_format("d mmmm yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("14 tháng 11 2023")); + s.set_date_format("mmmm yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("Tháng 11 2023")); + s.set_date_format("ddd, d mmm").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("T3, 14 Thg 11")); + // Encoding follows the spelled text: `14 tháng 11 2023` is WinAnsi-representable; + // a weekday name such as `Thứ Ba` is not, so Fill & Sign refuses that format. + assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("vi")).as_deref(), Ok("14 tháng 11 2023")); + let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("vi")).unwrap_err(); + assert!(e.contains("can't be written into the PDF yet") && e.contains("dd.mm.yyyy"), "{e}"); + assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("vi")).is_ok()); + } + #[test] fn dates_the_pdf_cannot_hold_are_refused_not_damaged() { assert_eq!(unwritable("8. října 2026"), "ř"); diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs index 5782b3d590..e279399270 100644 --- a/crates/ui-egui/src/appearance.rs +++ b/crates/ui-egui/src/appearance.rs @@ -111,7 +111,7 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The /// The Appearance section of Preferences. pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.label(RichText::new(tl!("Appearance")).font(theme::semibold(13.0))); + ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); ui.horizontal(|ui| { ui.label(tl!("Appearance mode")); egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 18a0fc54bf..b9a264f216 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -147,7 +147,7 @@ fn plural_sl(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 22] = [ +pub static LANGUAGES: [LangInfo; 23] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). @@ -207,6 +207,9 @@ pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Lithuanian; every `lt-*` locale (`lt-LT`, `lt_LT.UTF-8`) resolves here. Three plural forms (one, few, other). LangInfo { code: "lt", name: "Lietuvių", source: include_str!("lt.tsv"), plural: plural_lithuanian, rtl: false, catalog: OnceLock::new() }, + // Vietnamese; every `vi-*` locale (`vi-VN`, `vi_VN.UTF-8`) resolves here. CLDR `vi` counts have + // one form ("other"), so `@plural` entries list a single form. + LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -2259,6 +2262,243 @@ mod tests { ); } + #[test] + fn vietnamese_is_registered_and_persists() { + let vi = Lang::from_code("vi").expect("vi registered"); + assert_eq!(vi.name(), "Tiếng Việt"); + assert_eq!(normalize_pref("Vi"), Some("vi")); + for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] { + assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); + } + assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); + assert_eq!(tr(vi, "File"), "Tệp"); + assert_eq!(tr(vi, "Edit"), "Chỉnh sửa"); + assert_eq!(tr(vi, "Save"), "Lưu"); + assert_eq!(tr(vi, "Save as…"), "Lưu thành…"); + assert_eq!(tr(vi, "Bookmarks"), "Dấu trang"); + assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); + assert_eq!(tr(vi, "Interface language"), "Ngôn ngữ giao diện"); + assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); + for n in [0, 1, 2, 5] { + assert_eq!(trn(vi, n, "{n} page", "{n} pages"), format!("{n} trang")); + assert_eq!(trn(vi, n, "{n} field", "{n} fields"), format!("{n} trường")); + } + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "vi").unwrap(); + assert_eq!(app.language, "vi"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "vi"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "vi"); + } + + #[test] + fn vietnamese_covers_commands_and_catalogue() { + let vi = Lang::from_code("vi").expect("vi registered"); + let mut missing = std::collections::BTreeSet::new(); + for command in pdfcraft_engine::commands::COMMANDS { + if !has(vi, command.label) { + missing.insert(command.label); + } + if let Some(menu) = command.menu + && !has(vi, menu) + { + missing.insert(menu); + } + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + if !has(vi, group.label) { + missing.insert(group.label); + } + for section in group.sections { + if !has(vi, section.title) { + missing.insert(section.title); + } + for item in section.items { + if !has(vi, item.label) { + missing.insert(item.label); + } + } + } + } + assert!(missing.is_empty(), "Vietnamese misses commands/tools: {missing:#?}"); + } + + #[test] + fn vietnamese_covers_ui_literals() { + let vi = Lang::from_code("vi").expect("vi registered"); + let literals = scan_ui_literals(true); + let missing: Vec<_> = literals.iter().filter(|label| !has(vi, label)).collect(); + assert!(missing.is_empty(), "Untranslated Vietnamese UI labels: {missing:#?}"); + } + + #[test] + fn vietnamese_covers_contextual_calendar_certificate_and_preference_labels() { + let vi = Lang::from_code("vi").expect("vi registered"); + assert_eq!(tr_ctx(vi, "comment menu", "Edit"), "Sửa"); + assert_eq!(tr_ctx(vi, "signature pad", "Type"), "Nhập chữ"); + assert_eq!(tr_ctx(vi, "action wizard", "Start"), "Bắt đầu"); + assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(vi, "scroll speed", label)), ["Chậm", "Bình thường", "Nhanh"]); + let months = [ + ("January", "Tháng 1", "tháng 1"), + ("February", "Tháng 2", "tháng 2"), + ("March", "Tháng 3", "tháng 3"), + ("April", "Tháng 4", "tháng 4"), + ("May", "Tháng 5", "tháng 5"), + ("June", "Tháng 6", "tháng 6"), + ("July", "Tháng 7", "tháng 7"), + ("August", "Tháng 8", "tháng 8"), + ("September", "Tháng 9", "tháng 9"), + ("October", "Tháng 10", "tháng 10"), + ("November", "Tháng 11", "tháng 11"), + ("December", "Tháng 12", "tháng 12"), + ]; + for (source, standalone, in_date) in months { + assert_eq!(tr(vi, source), standalone); + assert_eq!(tr_ctx(vi, "calendar date", source), in_date); + } + assert_eq!( + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(vi, "calendar weekday", day)), + ["CN", "T2", "T3", "T4", "T5", "T6", "T7"] + ); + assert_eq!(tr_ctx(vi, "certificate", "Subject"), "Chủ thể chứng chỉ"); + assert_eq!(tr(vi, "Subject"), "Chủ đề"); + // Appearance: object/comment/signature look vs Preferences theme section. + assert_eq!(tr(vi, "Appearance"), "Diện mạo"); + assert_eq!(tr_ctx(vi, "theme", "Appearance"), "Giao diện"); + // PDF form Field — never the visual-box sense "ô". + assert_eq!(tr(vi, "Field"), "Trường"); + assert_eq!(tr(vi, "Fields"), "Các trường"); + assert_eq!(tr(vi, "Form fields"), "Các trường biểu mẫu"); + assert_eq!(tr(vi, "Text field"), "Trường văn bản"); + assert_eq!(tr(vi, "Date field"), "Trường ngày"); + assert_eq!(tr(vi, "Image field"), "Trường hình ảnh"); + assert_eq!(tr(vi, "Field properties"), "Thuộc tính trường"); + assert_eq!(tr(vi, "Field list"), "Danh sách trường"); + assert_eq!(tr(vi, "{kind} field"), "Trường {kind}"); + // Redaction: permanent removal, not temporary cover. + assert_eq!(tr(vi, "Mark for redaction"), "Đánh dấu để xóa"); + assert_eq!(tr(vi, "Apply redactions"), "Xóa vĩnh viễn nội dung đã đánh dấu"); + assert_eq!(tr(vi, "Redact text and images"), "Xóa nội dung nhạy cảm trong văn bản và hình ảnh"); + assert_eq!(tr(vi, "Clear form"), "Xóa dữ liệu biểu mẫu"); + // Fill & Sign tool section title — fill-in, not paint fill. + assert_eq!(tr(vi, "Fill"), "Điền"); + assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); + assert_eq!( + tr(vi, "Remove \"{name}\" from this list? The file itself is not deleted."), + "Gỡ \"{name}\" khỏi danh sách này? Bản thân tệp không bị xóa." + ); + for field in [ + "Issued to", + "Issued by", + "Valid from", + "Valid to", + "Intended usage", + "Serial number", + "Issuer", + "Validity starts", + "Validity ends", + "Public key", + "Basic constraints", + "Key usage", + "Self-signed", + "SHA-1 digest", + "SHA-256 digest", + ] { + assert_ne!(tr_ctx(vi, "certificate", field), field, "certificate field remains untranslated: {field}"); + } + } + + #[test] + fn vietnamese_covers_bundled_catalog_keys() { + let vi = Lang::from_code("vi").expect("vi registered"); + let mut missing = std::collections::BTreeSet::new(); + for reference in LANGUAGES.iter().filter(|language| language.code != "vi") { + let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); + assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); + for entry in entries { + let translated = match entry.context.as_str() { + "" => vi.catalog().plain(&entry.source), + "@id" => vi.catalog().id(&entry.source), + "@plural" => entry.source.split_once('|').and_then(|(one, other)| vi.catalog().plural(one, other, 0)), + context => vi.catalog().contextual(context, &entry.source), + }; + if translated.is_none() { + missing.insert((entry.context, entry.source)); + } + } + } + assert!(missing.is_empty(), "Vietnamese misses bundled catalog keys: {missing:#?}"); + } + + #[test] + fn vietnamese_covers_credits_labels() { + let vi = Lang::from_code("vi").expect("vi registered"); + let labels = crate::credits::NameMode::ALL + .iter() + .map(|mode| mode.label()) + .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { + let (menu, heading) = key.label(); + [menu, heading] + })) + .chain(crate::credits::MODEL_COLUMNS.iter().copied()); + let missing: Vec<_> = labels.filter(|label| !has(vi, label)).collect(); + assert!(missing.is_empty(), "Vietnamese misses credits labels: {missing:#?}"); + } + + #[test] + fn vietnamese_leaves_no_unapproved_english_fallbacks() { + let vi = Lang::from_code("vi").expect("vi registered"); + // Authoritative allowlist for intentional English-identical Vietnamese rows. + const KEEP_AS_IS: &[&str] = &[ + "OK", + "Digital ID", + "ZIP", + "JavaScript", + "Microsoft Word (.docx)", + "PDF/A…", + "PostScript / EPS", + "Rich Text Format (.rtf)", + "CheckBox", + "Combo", + "Home / End", + "PushButton", + "Radio", + "Standard Business", + "Keychain · ", + "{0}: {1}", + "{field}: {e}", + "{month} {y}", + "{n} {kind}", + "{rule} - {status}", + "+LOC", + "−LOC", + "ΔLOC", + "+Bin", + "−Bin", + "`{command}` {when}", + "English", + ]; + const KEY_CAPS: &[&str] = &["Ctrl", "Shift", "Delete", "Esc", "Home", "End", "Space"]; + let (entries, errors) = catalog::parse_entries(vi.0.source, vi.0.plural_forms()); + assert!(errors.is_empty(), "{errors:?}"); + let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); + let mut unchanged = Vec::new(); + for e in &entries { + let key_cap = e.context == "key" && KEY_CAPS.contains(&e.source.as_str()); + if e.source == e.translation && !KEEP_AS_IS.contains(&e.source.as_str()) && !key_cap { + unchanged.push((e.context.clone(), e.source.clone())); + } + assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); + assert_eq!(edges(&e.translation), edges(&e.source), "stray whitespace: {:?}", e.translation); + if e.context == "@plural" { + assert!(!e.translation.contains('|'), "Vietnamese @plural is one form: {:?}", e.translation); + } + } + assert!(unchanged.is_empty(), "unapproved English Vietnamese catalog entries: {unchanged:#?}"); + } + #[test] fn slovene_is_registered_and_persists() { let sl = Lang::from_code("sl").expect("sl registered"); diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv new file mode 100644 index 0000000000..9136fdfb4b --- /dev/null +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -0,0 +1,2601 @@ +# Vietnamese (vi) catalog. Columns, separated by a TAB: context source translation +# +# Clean-room translations from PdfCraft's English UI labels. Reviewed by a native +# Vietnamese-speaking contributor. PhotoCraft/VectorCraft Vietnamese catalogs were used only as +# translation memory when the English source and its meaning in context matched. Product and +# format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain names where appropriate. +# Plural rule: `plural_none` — every @plural row lists exactly one form. +# Context-sensitive wording and intentional unchanged strings are regression-tested in i18n/mod.rs. +# + Menu Trình đơn + Edit Chỉnh sửa + Pages Trang + View Xem + Help Trợ giúp + Zoom Thu phóng + Actual size Kích thước thực + Zoom to page level Thu phóng vừa trang + Fit to width Vừa chiều rộng + Display theme Chủ đề hiển thị + Side panels Bảng bên + Preferences Tùy chọn + Preferences… Tùy chọn… + Interface language Ngôn ngữ giao diện + Interface size Kích thước giao diện + Auto ({percent}%) Tự động ({percent}%) + Identity Danh tính + Name on new comments Tên trên bình luận mới + Open… Mở… + Open Recent Mở gần đây + Clear Recent Files Xóa tệp gần đây + Remove from Recent Xóa khỏi Gần đây + No recent files Không có tệp gần đây + New blank PDF PDF trống mới + Create PDF from file… Tạo PDF từ tệp… + Create PDF from images… Tạo PDF từ hình ảnh… + Create PDF from multiple files… Tạo PDF từ nhiều tệp… + Create separate PDFs… Tạo các PDF riêng biệt… + Create PDF from clipboard Tạo PDF từ bảng tạm + Combine files… Kết hợp tệp… + Save Lưu + Save as… Lưu thành… + Close file Đóng tệp + Close all Đóng tất cả + Revert Hoàn nguyên + Print… In… + Document properties… Thuộc tính tài liệu… + Find… Tìm… + Advanced search… Tìm nâng cao… + Copy Sao chép + Cut Cắt + Paste Dán + Delete Xóa + Select all Chọn tất cả + Cancel Hủy + OK OK + Apply Áp dụng + Close Đóng + Back Quay lại + Next Tiếp theo + Previous Trước + Name Tên + File name Tên tệp + Location Vị trí + Browse… Duyệt… + Choose… Chọn… + Search Tìm + Clear Xóa + Refresh Làm mới + Loading… Đang tải… + Save changes? Lưu các thay đổi? + Don't save Không lưu + Page Trang + Page {n} Trang {n} + of trên + Fit one full page Vừa một trang đầy đủ + Single page Một trang + Two-page view Chế độ hai trang + Continuous scrolling Cuộn liên tục + Page display Hiển thị trang + Default page display Hiển thị trang mặc định + Default zoom Thu phóng mặc định + Full screen mode Chế độ toàn màn hình + Read mode Chế độ đọc + Comments Bình luận + Comment Bình luận + Add comment Thêm bình luận + Delete comment Xóa bình luận + Reply Trả lời + Highlight Tô sáng + Underline Gạch chân + Strikethrough Gạch ngang + Text Văn bản + Font Phông chữ + Size Kích thước + Color Màu + Opacity Độ mờ + Width Chiều rộng + Height Chiều cao + Rotation Xoay + More Thêm + Settings Cài đặt + General Chung + Language Ngôn ngữ + Appearance Diện mạo +theme Appearance Giao diện + Theme Chủ đề + Light Sáng + Dark Tối + System Hệ thống + Open file Mở tệp + Save file Lưu tệp + Export Xuất + Import Nhập + Export to image… Xuất ra hình ảnh… + Export to text… Xuất ra văn bản… + Export to Word… Xuất ra Word… + Export to HTML… Xuất ra HTML… + Export to RTF… Xuất ra RTF… + Reduce file size… Giảm kích thước tệp… + Optimize PDF… Tối ưu hóa PDF… + Protect using password… Bảo vệ bằng mật khẩu… + Remove security Gỡ bảo mật + Security properties… Thuộc tính bảo mật… + Organize pages Sắp xếp trang + Rotate pages… Xoay trang… + Delete pages Xóa trang + Insert blank page Chèn trang trống + Insert pages from file… Chèn trang từ tệp… + Replace pages… Thay thế trang… + Extract pages… Trích xuất trang… + Split document… Tách tài liệu… + Number pages… Đánh số trang… + Bookmarks Dấu trang + New bookmark Dấu trang mới + Forms Biểu mẫu + Form fields Các trường biểu mẫu + Signatures Chữ ký + Sign Ký + Fill & Sign Điền & Ký + Accessibility Trợ năng + Help… Trợ giúp… + Check for updates… Kiểm tra bản cập nhật… + About PdfCraft Giới thiệu PdfCraft + Automatically Tự động + Never Không bao giờ + Always Luôn luôn + Yes Có + No Không + On Bật + Off Tắt + Enabled Đã bật + Disabled Đã tắt + Required Bắt buộc + Optional Không bắt buộc + None Không có + Unknown Không rõ + Error Lỗi + Warning Cảnh báo + Information Thông tin + Success Thành công + Failed Thất bại + File Tệp + Folder Thư mục + Choose a file… Chọn một tệp… + Choose a folder… Chọn một thư mục… + Open folder Mở thư mục + Create folder Tạo thư mục + Remove Gỡ bỏ + Rename Đổi tên + Duplicate Nhân bản + Move Di chuyển + Copy to clipboard Sao chép vào bảng tạm + Copied to clipboard Đã sao chép vào bảng tạm + Undo Hoàn tác + Redo Làm lại + Keyboard shortcuts Phím tắt + Find tools and commands… Tìm công cụ và lệnh… + Take a snapshot Chụp ảnh nhanh + Organize Sắp xếp + Comment and review Bình luận và duyệt + Prepare a form Chuẩn bị biểu mẫu + Protect Bảo vệ + Print In + Create Tạo + Convert Chuyển đổi + Optimize Tối ưu hóa + Measure Đo + Redact Xóa nội dung nhạy cảm + Search results Kết quả tìm + No results Không có kết quả + Page {n} of {total} Trang {n} trên {total} + {n} pages selected Đã chọn {n} trang + · not fixable here · không thể sửa tại đây + (page {p}) (trang {p}) + (searching… {s} of {p} pages) (đang tìm… {s} trên {p} trang) + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} ở {dpi} dpi, kích thước lớn nhất có thể vẽ) + ({s} not exported: {first}) ({s} chưa được xuất: {first}) + on page {p} trên trang {p} + · password-protected · được bảo vệ bằng mật khẩu + (none) (không có) + , Expires: , Hết hạn: + 1 difference found Tìm thấy 1 điểm khác biệt + 1 file 1 tệp + 1 file done 1 tệp đã xong + 1 file needs attention 1 tệp cần chú ý + 1 instance 1 lần xuất hiện + 1 issue 1 vấn đề + 1 mark 1 dấu + 1 page 1 trang + 1 page selected Đã chọn 1 trang + 1 page selected. Đã chọn 1 trang. + 180 degrees 180 độ + 9 digit, A letter, O letter or digit, X any character 9 chữ số, A chữ cái, O chữ cái hoặc chữ số, X ký tự bất kỳ + ; 1 page already had text ; 1 trang đã có văn bản + ; failed: {list} ; thất bại: {list} + ; {n} pages already had text ; {n} trang đã có văn bản + ? of {n} ? trên {n} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. Ứng dụng PDF mã nguồn mở, viết lại độc lập (clean-room) bằng Rust. MIT OR Apache-2.0. + Accessibility Checker Trình kiểm tra trợ năng + Accessibility Checker Options Tùy chọn trình kiểm tra trợ năng + Acrobat 3.0 and later (40-bit RC4) Acrobat 3.0 trở lên (RC4 40 bit) + Acrobat 5.0 and later (128-bit RC4) Acrobat 5.0 trở lên (RC4 128 bit) + Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 trở lên (AES 128 bit) + Acrobat X and later (256-bit AES) Acrobat X trở lên (AES 256 bit) + Action name: Tên hành động: + Actions Hành động + Active Đang dùng + Actual Size Kích thước thực + Actual text Văn bản thực tế + Add Thêm + Add Step Thêm bước + Add a comment Thêm bình luận + Add a reply Thêm câu trả lời + Add a sticky note here Thêm ghi chú dán tại đây + Add an Action Thêm hành động + Add at least two files Thêm ít nhất hai tệp + Add files… Thêm tệp… + Add folder Thêm thư mục + Add folder and subfolders Thêm thư mục và thư mục con + Add folder and subfolders… Thêm thư mục và thư mục con… + Add folder… Thêm thư mục… + Add open documents Thêm tài liệu đang mở + Add the choices people pick from. Thêm các lựa chọn để người dùng chọn. + Add the files to combine Thêm các tệp cần kết hợp + Add the steps this action runs, in order. Thêm các bước mà hành động này chạy, theo thứ tự. + Add to Trusted Certificates Thêm vào Chứng chỉ tin cậy + Add to trusted certificates Thêm vào chứng chỉ tin cậy + Added 1 comment Đã thêm 1 bình luận + Added more than once Đã thêm nhiều lần + Added {n} comments Đã thêm {n} bình luận + Adds invisible text over each word; the page image is not changed Thêm văn bản ẩn lên từng từ; hình ảnh trang không bị thay đổi + Advanced Nâng cao + Advanced Options {chevron} Tùy chọn nâng cao {chevron} + Align Căn chỉnh + Align left Căn trái + Align right Căn phải + Alignment: Căn lề: + All Tất cả + All document contents Toàn bộ nội dung tài liệu + All document contents except metadata Toàn bộ nội dung tài liệu trừ siêu dữ liệu + All open documents Tất cả tài liệu đang mở + All pages Tất cả các trang + All pages in document Tất cả các trang trong tài liệu + All pages in range Tất cả các trang trong phạm vi + Allow Cho phép + Allow Rich Text Formatting Cho phép định dạng văn bản phong phú + Allow user to enter custom text Cho phép người dùng nhập văn bản tùy ý + Also recognize pages that already have text Nhận dạng cả các trang đã có văn bản + Alternate text Văn bản thay thế + An action with this name already exists. Đã có hành động với tên này. + An open-source PDF workbench — local, private, and scriptable. Bộ công cụ PDF mã nguồn mở — chạy cục bộ, riêng tư và có thể viết kịch bản. + Angle Góc + Animation effects Hiệu ứng hoạt họa + Annotations, form fill-in, and digital signatures Chú thích, điền biểu mẫu và chữ ký số + Any Bất kỳ + Any except extracting pages Bất kỳ trừ trích xuất trang + Anyone opening the document will need this password. It's applied when you save. Bất kỳ ai mở tài liệu đều cần mật khẩu này. Mật khẩu được áp dụng khi bạn lưu. + Appear behind page Hiện phía sau trang + Appear on top of page Hiện phía trên trang + Appearance mode Chế độ giao diện + Appearance settings… Cài đặt giao diện… + Applies to 1 page Áp dụng cho 1 trang + Applies to {n} pages Áp dụng cho {n} trang + Apply redactions Xóa vĩnh viễn nội dung đã đánh dấu + Apply scale Áp dụng tỷ lệ + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Hỏi GitHub về bản phát hành mới nhất. Không có gì được tải xuống hoặc cài đặt tự động. + At most Tối đa + Attachments Tệp đính kèm + Attachments Panel and Page Bảng Tệp đính kèm và trang + Audit space usage… Kiểm tra dung lượng sử dụng… + Author Tác giả + Auto Tự động + Auto follows the system's text size. Pages always render at the display's full resolution. Chế độ Tự động theo kích thước chữ của hệ thống. Các trang luôn được hiển thị ở độ phân giải đầy đủ của màn hình. + Auto portrait/landscape Tự động dọc/ngang + Auto-rotate pages Tự động xoay trang + Auto-size text to fit redaction region Tự động chỉnh cỡ chữ vừa vùng xóa + Available Có sẵn + Back side only Chỉ mặt sau + Back to all tools Quay lại tất cả công cụ + Back to the document Quay lại tài liệu + Before each selected page (select pages in Organize) Trước mỗi trang đã chọn (chọn trang trong Sắp xếp) + Before the number Trước số + Bicubic downsampling to Giảm mẫu song khối (Bicubic) xuống + Binding Đóng gáy + Binding: Đóng gáy: + Black and white Đen trắng + Bold Đậm + Booklet Sách nhỏ + Booklet subset: Tập con sách nhỏ: + Bookmark options Tùy chọn dấu trang + Bookmarks Panel and Page Bảng Dấu trang và trang + Border Color: Màu viền: + Borders and Colors Viền và màu + Both sides Cả hai mặt + Bottom Dưới cùng + Bottom: Dưới: + Box Hộp + Browser's print dialog Hộp thoại in của trình duyệt + Butt Cụt + Button is checked by default Nút được chọn theo mặc định + Buttons with the same name and value are selected in unison Các nút có cùng tên và giá trị được chọn đồng thời + Bytes Byte + Calibrate from two points Hiệu chuẩn từ hai điểm + Can't be read: {e} Không thể đọc: {e} + Cancelling… Đang hủy… + Case-sensitive Phân biệt chữ hoa/thường + Category: Danh mục: + Center Giữa + Center window on screen Căn giữa cửa sổ trên màn hình + Centre Giữa + Certify Chứng nhận + Change settings… Thay đổi cài đặt… + Change the colour of line art as well as text Đổi màu của nét vẽ đường cũng như văn bản + Change {what} Thay đổi {what} + Changes allowed Các thay đổi được phép + Character limit 0 means no limit. A comb of characters needs a limit. Giới hạn ký tự bằng 0 nghĩa là không giới hạn. Ô dạng lược ký tự cần có giới hạn. + Character spacing Khoảng cách ký tự + Check Again Kiểm tra lại + Check again Kiểm tra lại + Check box is checked by default Hộp kiểm được chọn theo mặc định + Check for updates Kiểm tra bản cập nhật + Check spelling Kiểm tra chính tả + Check the pages Kiểm tra các trang + Check the pages of {name}: {why} Kiểm tra các trang của {name}: {why} + Checking Options ({on} of 32 in all categories) Tùy chọn kiểm tra ({on} trên 32 ở tất cả danh mục) + Checking for a newer version… Đang kiểm tra phiên bản mới hơn… + Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Chọn ảnh PNG hoặc JPEG (tối đa 4 MiB). Độ trong suốt được giữ nguyên. + Choose a folder for the exported files Chọn thư mục cho các tệp đã xuất + Choose a folder for the extracted pages Chọn thư mục cho các trang đã trích xuất + Choose a folder for the results Chọn thư mục cho kết quả + Choose a folder for the searchable files Chọn thư mục cho các tệp có thể tìm kiếm + Choose a layout: Chọn bố cục: + Choose a stamp, then click on the page to place it. Chọn một con dấu, sau đó bấm vào trang để đặt. + Choose an image Chọn một hình ảnh + Choose images Chọn hình ảnh + Choose image… Chọn hình ảnh… + Choose photo… Chọn ảnh… + Choose the digital ID that you want to use for signing: Chọn Digital ID mà bạn muốn dùng để ký: + Circle Hình tròn + Clean Up Dọn dẹp + Clean-up Dọn dẹp + Clear All Xóa tất cả + Clear all Xóa tất cả + Clear selection / close find Bỏ chọn / đóng tìm + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. Bấm vào trang để thêm văn bản; mỗi lần bấm sẽ tạo một khung mới, và ✓ để hoàn tất. Kéo các mục để di chuyển, kéo một góc để đổi kích thước, bấm đúp vào văn bản để chỉnh sửa. + Click the first point to begin. Bấm vào điểm đầu tiên để bắt đầu. + Click to sort, drag to move the column. Drag its edge to resize it. Bấm để sắp xếp, kéo để di chuyển cột. Kéo cạnh cột để đổi kích thước. + Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. Bấm vào các đỉnh. Nhấn Enter hoặc bấm đúp để kết thúc chu vi hoặc diện tích. Bấm vào điểm đầu tiên để đóng một vùng diện tích. + Clockwise 90 degrees Theo chiều kim đồng hồ 90 độ + Close (Esc) Đóng (Esc) + Close Tab Đóng thẻ + Close panel Đóng bảng + Closed arrow Mũi tên đóng + Collapse Thu gọn + Collapse all Thu gọn tất cả + Collapse all bookmarks Thu gọn tất cả dấu trang + Collapse tag Thu gọn thẻ + Collate Sắp theo bộ + Color: Màu: + Colour Màu + Colours are remapped while rendering; the document itself never changes. Màu được ánh xạ lại khi hiển thị; bản thân tài liệu không bao giờ thay đổi. + Comb of Dạng lược gồm + Combine the rest Kết hợp phần còn lại + Combined {n} files Đã kết hợp {n} tệp + Coming in milestone Sẽ có ở cột mốc + Comment and form data Dữ liệu bình luận và biểu mẫu + Commenting, filling in form fields, and signing existing signature fields Bình luận, điền các trường biểu mẫu và ký vào các trường chữ ký có sẵn + Comments & Forms Bình luận & Biểu mẫu + Comments only Chỉ bình luận + Commit selected value immediately Lưu giá trị đã chọn ngay lập tức + Common Properties Thuộc tính chung + Compare Files So sánh tệp + Compare this file with an older version: Compare files… in All tools. So sánh tệp này với phiên bản cũ hơn: So sánh tệp… trong Tất cả công cụ. + Compatibility Tương thích + Compress document structure (object streams) Nén cấu trúc tài liệu (luồng đối tượng) + Compression Nén + Configure New Digital ID Cấu hình Digital ID mới + Confirm Password Xác nhận mật khẩu + Conformance level: Mức tuân thủ: + Continue Tiếp tục + Copies, two-sided printing and colour are chosen in the browser's print dialog. Số bản sao, in hai mặt và màu được chọn trong hộp thoại in của trình duyệt. + Copies: Số bản sao: + Copy selected text Sao chép văn bản đã chọn + Copy text Sao chép văn bản + Couldn't check for updates: {e} Không thể kiểm tra bản cập nhật: {e} + Couldn't open the printer's preferences: {e} Không thể mở tùy chọn của máy in: {e} + Counterclockwise 90 degrees Ngược chiều kim đồng hồ 90 độ + Country/Region Quốc gia/Khu vực + Create Custom Stamp Tạo con dấu tùy chỉnh + Create Link Tạo liên kết + Create PDF Comment Summary Tạo bản tóm tắt bình luận PDF + Create PDF comment summary… Tạo bản tóm tắt bình luận PDF… + Create PDF from images Tạo PDF từ hình ảnh + Create a new Digital ID (self-signed, saved to a password-protected file) Tạo Digital ID mới (tự ký, lưu vào tệp được bảo vệ bằng mật khẩu) + Create accessibility report Tạo báo cáo trợ năng + Create custom stamp… Tạo con dấu tùy chỉnh… + Create {what} Tạo {what} + Created a PDF from {n} files Đã tạo một PDF từ {n} tệp + Created a PDF from {n} files; left out: {names} Đã tạo một PDF từ {n} tệp; đã bỏ qua: {names} + Creates 1 file from {n} pages. Tạo 1 tệp từ {n} trang. + Creates {f} files from {n} pages. Tạo {f} tệp từ {n} trang. + Crop (% trimmed from each side) Cắt xén (% bị cắt từ mỗi cạnh) + Currency symbol: Ký hiệu tiền tệ: + Current page Trang hiện tại + Current page ({n}) Trang hiện tại ({n}) + Current page — type a page number or label (such as iv) and press Enter Trang hiện tại — nhập số trang hoặc nhãn (như iv) rồi nhấn Enter + Custom Tùy chỉnh + Custom scale: Tỷ lệ tùy chỉnh: + Custom text: Văn bản tùy chỉnh: + Custom: Tùy chỉnh: + Cut and stack Cắt và xếp chồng + Cut marks Dấu cắt + DPI sets the printed page size without resampling the image. DPI đặt kích thước trang in mà không lấy mẫu lại hình ảnh. + Dark theme Chủ đề tối + Date format Định dạng ngày + Decimal places: Số chữ số thập phân: + Decimals Số thập phân + Declared conformance: {shown} Tuân thủ đã khai báo: {shown} + Decorative figure Hình trang trí + Default Mặc định + Default Value: Giá trị mặc định: + Default workspace mode Chế độ không gian làm việc mặc định + Default: Mặc định: + Delete page {label} Xóa trang {label} + Delete pages (Delete) Xóa trang (Delete) + Delete pages after extracting Xóa trang sau khi trích xuất + Delete selected pages (Organize) Xóa các trang đã chọn (Sắp xếp) + Delete stamp Xóa con dấu + Delete {o} Xóa {o} + Describe the figure Mô tả hình + Description Mô tả + Description: Mô tả: + Details Chi tiết + Diamond Hình thoi + Digital ID Digital ID + Digital ID password Mật khẩu Digital ID + Direction: Hướng: + Discard Loại bỏ + Discard Objects Loại bỏ đối tượng + Discard User Data Loại bỏ dữ liệu người dùng + Discard alternate images Loại bỏ hình ảnh thay thế + Discard document tags Loại bỏ thẻ tài liệu + Discard embedded page thumbnails Loại bỏ hình thu nhỏ trang nhúng + Discard embedded print settings Loại bỏ cài đặt in nhúng + Discard this text (Esc) Loại bỏ văn bản này (Esc) + Dismiss Đóng + Distinguished name Tên phân biệt + Distribute Phân bố + Document Tài liệu + Document Properties Thuộc tính tài liệu + Document Title Tiêu đề tài liệu + Document and markups Tài liệu và chú thích đánh dấu + Document and stamps Tài liệu và con dấu + Document language Ngôn ngữ tài liệu + Document properties ({key}) Thuộc tính tài liệu ({key}) + Documents and view Tài liệu và chế độ xem + Done Xong + Done adding text Đã thêm văn bản xong + Download Tải xuống + Downsample to Giảm mẫu xuống + Drag the selection to move it. Esc clears the selection. Kéo vùng chọn để di chuyển. Esc để bỏ chọn. + Drag to reorder Kéo để sắp xếp lại + Draw Vẽ + Draw your {what} below. Vẽ {what} của bạn bên dưới. + Drawing scale changes. Use Actual size for full-scale plans. Tỷ lệ bản vẽ thay đổi. Dùng Kích thước thực cho các bản vẽ tỷ lệ đầy đủ. + Drop PDFs, images, text files or folders here, or choose them. Thả PDF, hình ảnh, tệp văn bản hoặc thư mục vào đây, hoặc chọn chúng. + Duplicate "{name}" onto: Nhân bản "{name}" sang: + Duplicate Field Nhân bản trường + Duplicate… Nhân bản… + Dynamic Động + Dynamic XFA form laid out from its template: {pages}, {fields}. Biểu mẫu XFA động được bố trí từ mẫu của nó: {pages}, {fields}. + Each file holds as many of the {n} pages as fit. Mỗi tệp chứa càng nhiều trang trong số {n} trang càng tốt. + Each image is measured where pages draw it; an image is replaced only if the result is smaller. Mỗi hình ảnh được đo tại nơi các trang vẽ nó; hình ảnh chỉ được thay thế nếu kết quả nhỏ hơn. + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. Mỗi hình ảnh một lần, đặt tên theo tài liệu và trang. Ảnh JPEG được lưu nguyên vẹn; các ảnh khác lưu dưới dạng PNG. + Earlier in tab order Sớm hơn theo thứ tự tab + Edit Action Chỉnh sửa hành động + Edit image Chỉnh sửa hình ảnh + Edit text… Chỉnh sửa văn bản… + Edit… Chỉnh sửa… + Email Address Địa chỉ email + Enable Acrobat JavaScript Bật Acrobat JavaScript + Enable copying of text, images, and other content Cho phép sao chép văn bản, hình ảnh và nội dung khác + Enable text access for screen reader devices for the visually impaired Cho phép truy cập văn bản cho thiết bị đọc màn hình dành cho người khiếm thị + Encrypt Mã hóa + Endpoints Điểm cuối + Enhance page Nâng cao chất lượng trang + Enter the password that opens it Nhập mật khẩu để mở tài liệu + Enter the permissions password to allow combining Nhập mật khẩu quyền hạn để cho phép kết hợp + Entire page Toàn bộ trang + Even Pages Only Chỉ các trang chẵn + Even and Odd Pages Các trang chẵn và lẻ + Even pages only Chỉ các trang chẵn + Every PDF, image and text file in the folder Mọi tệp PDF, hình ảnh và văn bản trong thư mục + Every PDF, image and text file in the folder and the folders inside it Mọi tệp PDF, hình ảnh và văn bản trong thư mục và các thư mục con của nó + Example: {sample} Ví dụ: {sample} + Exclude images smaller than Loại trừ hình ảnh nhỏ hơn + Expand Mở rộng + Expand all Mở rộng tất cả + Expand all bookmarks Mở rộng tất cả dấu trang + Expand tag Mở rộng thẻ + Expand top-level bookmarks Mở rộng các dấu trang cấp cao nhất + Explain Giải thích + Export All Images Xuất tất cả hình ảnh + Export Value: {on} Giá trị xuất: {on} + Export all to data file… Xuất tất cả ra tệp dữ liệu… + Export certificate… Xuất chứng chỉ… + Export comments Xuất bình luận + Export form data Xuất dữ liệu biểu mẫu + Export stopped: {e} Việc xuất đã dừng: {e} + Export to Image Xuất ra hình ảnh + Export to Text Xuất ra văn bản + Exported 1 image Đã xuất 1 hình ảnh + Exported the text of 1 page Đã xuất văn bản của 1 trang + Exported the text of {n} pages Đã xuất văn bản của {n} trang + Exported {n} images Đã xuất {n} hình ảnh + Exporting… {d} of {t} Đang xuất… {d} trên {t} + Export… Xuất… + Extract pages as separate files Trích xuất trang thành các tệp riêng + Extract pages to a new document Trích xuất trang sang tài liệu mới + Extracted 1 page Đã trích xuất 1 trang + Extracted {n} pages Đã trích xuất {n} trang + Field is used for file selection Trường được dùng để chọn tệp + Field names (comma-separated) Tên trường (phân tách bằng dấu phẩy) + Field names with + - * / and parentheses; put \\ before spaces in names. Tên trường với + - * / và dấu ngoặc đơn; đặt \\ trước dấu cách trong tên. + + Field value is in range: Giá trị trường nằm trong phạm vi: + Field value is not validated Giá trị trường không được kiểm tra hợp lệ + Field: {field} on page {p} Trường: {field} trên trang {p} + Fields to reset (comma-separated; empty: all) Các trường cần đặt lại (phân tách bằng dấu phẩy; để trống: tất cả) + Figure {i} of {n}{page} Hình {i} trên {n}{page} + File (.p12, .pfx) Tệp (.p12, .pfx) + File Name Tên tệp + File size Kích thước tệp + Files are combined from top to bottom. Leave Pages empty to take every page. Các tệp được kết hợp từ trên xuống dưới. Để trống mục Trang để lấy mọi trang. + Files are combined in the order shown. Drag them to change it. Các tệp được kết hợp theo thứ tự hiển thị. Kéo chúng để thay đổi. + Files for {name} Tệp cho {name} + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Các tệp bạn mở trong PdfCraft sẽ xuất hiện ở đây. Thả một PDF vào bất kỳ đâu để mở. + Fill & Sign dates Ngày của Điền & Ký + Fill & Sign signature Chữ ký của Điền & Ký + Fill Color: Màu tô: + Fill colour Màu tô + Fill colour: none Màu tô: không có + Filling in form fields and signing existing signature fields Điền các trường biểu mẫu và ký vào các trường chữ ký có sẵn + Filter comments Lọc bình luận + Find a tag Tìm một thẻ + Find options Tùy chọn tìm + Find text Tìm văn bản + Find tools and commands Tìm công cụ và lệnh + Finding document fonts… Đang tìm phông chữ của tài liệu… + Finish or discard the text being edited before selecting several objects. Hãy hoàn tất hoặc loại bỏ văn bản đang chỉnh sửa trước khi chọn nhiều đối tượng. + First / last page Trang đầu / cuối + Fit Vừa khớp + Fit Height Vừa chiều cao + Fit Page Vừa trang + Fit Visible Vừa vùng hiển thị + Fit Width Vừa chiều rộng + Fit to height Vừa chiều cao + Fit to page Vừa trang + Fix Sửa lỗi + Fixed what could be fixed; 1 problem remains Đã sửa những gì có thể sửa; còn lại 1 vấn đề + Fixed what could be fixed; {n} problems remain Đã sửa những gì có thể sửa; còn lại {n} vấn đề + Flatten Fill & Sign when saving Làm phẳng Điền & Ký khi lưu + Flip Horizontal Lật ngang + Flip Vertical Lật dọc + Flip horizontal Lật ngang + Flip on long edge Lật theo cạnh dài + Flip on short edge Lật theo cạnh ngắn + Flip vertical Lật dọc + Folders Thư mục + Font Size: Cỡ chữ: + Font colour: Màu chữ: + Font size 0 uses automatic sizing. Cỡ chữ 0 sẽ dùng kích thước tự động. + Font size: Cỡ chữ: + Font: Phông chữ: + Font: Helvetica Phông chữ: Helvetica + Fonts Phông chữ + For page sizes and margins. Dành cho kích thước trang và lề. + Form data and PDF forms Dữ liệu biểu mẫu và biểu mẫu PDF + Form fields are flattened: their values stay visible. Saving rewrites the whole file. Các trường biểu mẫu được làm phẳng: giá trị của chúng vẫn hiển thị. Việc lưu sẽ ghi lại toàn bộ tệp. + Form fields only Chỉ các trường biểu mẫu + Form fill-in and digital signatures Điền biểu mẫu và chữ ký số + Format Định dạng + Format text Định dạng văn bản + Format: Định dạng: + From Từ + From a PDF page or an image Từ một trang PDF hoặc một hình ảnh + From colour Từ màu + From {file} Từ {file} + From: Từ: + Front side only Chỉ mặt trước + Get help, share feedback and follow development on Discord. Nhận trợ giúp, chia sẻ phản hồi và theo dõi quá trình phát triển trên Discord. + Go to a page view Đi tới một chế độ xem trang + Go to page {p} Đi tới trang {p} + Go to the page Đi tới trang + Go to this result Đi tới kết quả này + Graphic Đồ họa + Greyscale Thang xám + Grid view Dạng lưới + Hand (H) Bàn tay (H) + Has JavaScript actions (run in M6) Có các hành động JavaScript (chạy ở M6) + Height: Chiều cao: + Hide field highlights Ẩn tô sáng trường + Hide layer Ẩn lớp + Hide menu bar Ẩn thanh trình đơn + Hide password Ẩn mật khẩu + Hide toolbars Ẩn thanh công cụ + Hide window controls Ẩn các nút điều khiển cửa sổ + High Resolution Độ phân giải cao + High resolution Độ phân giải cao + Highlight Style: Kiểu tô sáng: + Highlight fields Tô sáng các trường + Home Trang chủ + Horizontal Ngang + Horizontal reversed Ngang đảo ngược + Horizontal scale Tỷ lệ ngang + Icon Biểu tượng + Images and text Hình ảnh và văn bản + Images and text (converted to PDF) Hình ảnh và văn bản (đã chuyển thành PDF) + Import list… Nhập danh sách… + Includes unsaved changes Bao gồm các thay đổi chưa lưu + Indent Thụt lề + Initial View Chế độ xem ban đầu + Ink sensitivity Độ nhạy nét mực + Insert Date Chèn ngày + Insert Page Number Chèn số trang + Insert a blank page after the selection Chèn một trang trống sau vùng chọn + Insert a file at the end Chèn một tệp vào cuối + Insert a file before page {label} Chèn một tệp trước trang {label} + Insert pages from a file… Chèn trang từ một tệp… + Inserting, deleting, and rotating pages Chèn, xóa và xoay trang + Installed fonts… Phông chữ đã cài đặt… + Intersections Giao điểm + Invisible Rectangle Hình chữ nhật ẩn + Issued by: Cấp bởi: + Italic Nghiêng + Item List Danh sách mục + Item: Mục: + Items marked Ready work today. Các mục được đánh dấu Sẵn sàng hoạt động ngay hôm nay. + Its security settings don't allow copying pages Cài đặt bảo mật của nó không cho phép sao chép trang + JavaScript Console Bảng điều khiển JavaScript + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript đã bị tắt (Tùy chọn > JavaScript). + JavaScript, e.g. getField("total").value JavaScript, ví dụ getField("total").value + Join our Discord Tham gia Discord của chúng tôi + Join the ArtCraft community Tham gia cộng đồng ArtCraft + Justify Căn đều + Keep source fonts Giữ phông chữ nguồn + Keep tool selected Giữ công cụ được chọn + Keep tool selected: on Giữ công cụ được chọn: bật + Key Algorithm Thuật toán khóa + Keychain · Keychain · + Label Nhãn + Labels Nhãn + Labels: {preview}. Later pages keep their labels. Nhãn: {preview}. Các trang sau giữ nhãn của chúng. + Landscape Ngang + Landscape Pages Trang ngang + Landscape and Portrait Pages Trang ngang và dọc + Larger pages Trang lớn hơn + Later Để sau + Later in tab order Muộn hơn theo thứ tự tab + Layers Các lớp + Layers Panel and Page Bảng Các lớp và trang + Layout and Magnification Bố cục và độ phóng đại + Leave out files that can't be read? Bỏ qua các tệp không đọc được? + Left Trái + Left Edge Cạnh trái + Left out: {names} Đã bỏ qua: {names} + Left: Trái: + Light theme Chủ đề sáng + Limit of Giới hạn + Line Style: Kiểu đường: + Line Thickness: Độ dày đường: + Line ending Kiểu đầu đường + Line spacing Giãn dòng + Line thickness Độ dày đường + Link Action Hành động liên kết + Link Appearance Diện mạo liên kết + Link Properties Thuộc tính liên kết + Link Type: Loại liên kết: + List view Dạng danh sách + Load font… Tải phông chữ… + Locked Đã khóa + Looking for PDFs… Đang tìm PDF… + Looking for printers… Đang tìm máy in… + Low Resolution (150 dpi) Độ phân giải thấp (150 dpi) + Low resolution Độ phân giải thấp + Magnification Độ phóng đại + Make Current Properties Default Đặt thuộc tính hiện tại làm mặc định + Margins Lề + Mark Page Range Đánh dấu phạm vi trang + Mark as comments Đánh dấu là bình luận + Mark with checkmark Đánh dấu bằng dấu kiểm + Marked Đã đánh dấu + Mask: Mặt nạ: + Match page at 100% Khớp trang ở mức 100% + Medium Vừa + Midpoints Điểm giữa + Mixed Hỗn hợp + Modified Đã sửa đổi + More options Thêm tùy chọn + More options: Thêm tùy chọn: + More ways to add files Thêm cách để thêm tệp + Mouse wheel scroll speed Tốc độ cuộn bánh xe chuột + Move down Chuyển xuống + Move earlier Chuyển lên trước + Move later Chuyển ra sau + Move up Chuyển lên + Multi-line Nhiều dòng + Multiple Nhiều + Multiple selection Chọn nhiều + Multiple words or phrases Nhiều từ hoặc cụm từ + Name: Tên: + Navigation tab Thẻ điều hướng + Needs manual check Cần kiểm tra thủ công + Negative number style: Kiểu số âm: + New Action Hành động mới + New Action… Hành động mới… + New bookmark ({key}) Dấu trang mới ({key}) + New file: Tệp mới: + Next ({key}) Tiếp theo ({key}) + Next / previous match Kết quả khớp tiếp theo / trước đó + Next figure Hình tiếp theo + Next month Tháng sau + Next page Trang tiếp theo + Next sheet Tờ tiếp theo + Next view Chế độ xem tiếp theo + No PDFs in this folder yet Chưa có PDF nào trong thư mục này + No actions Không có hành động + No changes allowed Không cho phép thay đổi + No changes to save Không có thay đổi để lưu + No check has run yet. Chưa chạy lần kiểm tra nào. + No color Không màu + No colour Không màu + No comments match. Không có bình luận nào khớp. + No comments yet. Chưa có bình luận. + No differences in the text. Không có khác biệt trong văn bản. + No digital IDs yet. Chưa có Digital ID nào. + No documents are open Không có tài liệu nào đang mở + No file chosen Chưa chọn tệp + No fill Không tô + No fonts are referenced by the pages. Không có phông chữ nào được các trang tham chiếu. + No issues found Không tìm thấy vấn đề + No limit Không giới hạn + No matches Không có kết quả khớp + No matches. Không có kết quả khớp. + No matching tools Không có công cụ phù hợp + No openly licensed document fonts found. Load a font file. Không tìm thấy phông chữ tài liệu có giấy phép mở. Hãy tải một tệp phông chữ. + No pages selected Chưa chọn trang + No photo chosen Chưa chọn ảnh + No preview Không có bản xem trước + No problems found: the document conforms to {level}. Không tìm thấy vấn đề: tài liệu tuân thủ {level}. + No security Không có bảo mật + No sheets Không có tờ + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Không tìm thấy chữ ký trong khung. Hãy di chuyển khung lên chữ ký hoặc tăng độ nhạy nét mực. + No status has been set. Chưa đặt trạng thái. + None (prefix only) Không có (chỉ tiền tố) + None found Không tìm thấy + None — everything is allowed Không có — mọi thứ đều được phép + Not allowed Không được phép + Not present Không có mặt + Nothing to redo Không có gì để làm lại + Nothing to undo Không có gì để hoàn tác + Number Số + Number of pages Số trang + Number pages Đánh số trang + Odd Pages Only Chỉ các trang lẻ + Odd pages only Chỉ các trang lẻ + Offset from centre Độ lệch so với tâm + Old file: Tệp cũ: + One word or phrase per line Mỗi dòng một từ hoặc cụm từ + One {f} file per page, named after the document. Mỗi trang một tệp {f}, đặt tên theo tài liệu. + Only change the colour of black text or line art Chỉ đổi màu văn bản hoặc nét vẽ đường màu đen + Only checked properties will change. Mixed means the selected fields have different values. Chỉ các thuộc tính được đánh dấu mới thay đổi. Hỗn hợp nghĩa là các trường đã chọn có giá trị khác nhau. + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. Chỉ nội dung trang thay đổi: liên kết, bình luận, trường biểu mẫu và dấu trang trên các trang gốc được giữ nguyên. + Open Mở + Open a PDF ({key}) Mở một PDF ({key}) + Open a PDF to measure it. Mở một PDF để đo. + Open a document first Hãy mở một tài liệu trước + Open a web page Mở một trang web + Open arrow Mũi tên mở + Open email app Mở ứng dụng email + Open in Full Screen mode Mở ở chế độ toàn màn hình + Open in a new tab Mở trong thẻ mới + Open link Mở liên kết + Open the file as it was saved then Mở tệp như khi nó được lưu lúc đó + Open the older version first Hãy mở phiên bản cũ hơn trước + Open this web page? Mở trang web này? + Open to page Mở tại trang + Opened, but its security settings don't allow combining. Enter the permissions password. Đã mở, nhưng cài đặt bảo mật của nó không cho phép kết hợp. Hãy nhập mật khẩu quyền hạn. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Mở tùy chọn của trình điều khiển máy in. Các tùy chọn này được lưu cho mọi lần in từ máy tính này; số bản sao, in hai mặt, thang xám và giấy được chọn ở đây vẫn được áp dụng. + Optimizing… cleaning up Đang tối ưu hóa… dọn dẹp + Optimizing… image {d} of {t} Đang tối ưu hóa… hình ảnh {d} trên {t} + Optimizing… merging identical objects Đang tối ưu hóa… gộp các đối tượng giống nhau + Optimizing… removing user data Đang tối ưu hóa… xóa dữ liệu người dùng + Optimizing… writing the copy Đang tối ưu hóa… ghi bản sao + Optional: needs an AI provider you configure Không bắt buộc: cần nhà cung cấp AI mà bạn cấu hình + Options… Tùy chọn… + Organization Name Tên tổ chức + Organizational Unit Đơn vị tổ chức + Orientation: Hướng: + Original Bản gốc + Original: replace pages Bản gốc: thay thế trang + Other colour… Màu khác… + Outdent Giảm thụt lề + Output Đầu ra + Output appears here. Đầu ra xuất hiện ở đây. + Output intent: {list} Mục đích đầu ra: {list} + Overlap: Chồng lấp: + Owner password (no restrictions apply) Mật khẩu chủ sở hữu (không áp dụng hạn chế) + PDF Optimizer Trình tối ưu hóa PDF + PDF or image PDF hoặc hình ảnh + PDF page: {size} Trang PDF: {size} + PDF scale: {scale}% Tỷ lệ PDF: {scale}% + PDF, images and text PDF, hình ảnh và văn bản + Page Only Chỉ trang + Page Range Options Tùy chọn phạm vi trang + Page Sizing & Handling Định cỡ & xử lý trang + Page background Nền trang + Page display: {layout} Hiển thị trang: {layout} + Page layout Bố cục trang + Page navigation Điều hướng trang + Page number Số trang + Page order: Thứ tự trang: + Page size Khổ trang + Page {label} Trang {label} + Page {p} of {n} Trang {p} trên {n} + Pages Panel and Page Bảng Trang và trang + Pages from Các trang từ + Pages of {name} to combine, e.g. 1-3, 6 Các trang của {name} cần kết hợp, ví dụ 1-3, 6 + Pages per file Số trang mỗi tệp + Pages per sheet: Số trang mỗi tờ: + Pages to Print Các trang cần in + Pages: Trang: + Pages: {k} of {n} Trang: {k} trên {n} + Paper: Giấy: + Part of Một phần của + Password Mật khẩu + Password required Yêu cầu mật khẩu + Password security Bảo mật bằng mật khẩu + Password-protected Được bảo vệ bằng mật khẩu + Paste after Dán sau + Paths Đường dẫn + Pattern Họa tiết + Patterns Họa tiết + Patterns (graphics objects) Họa tiết (đối tượng đồ họa) + PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft không đóng bình thường. Các tài liệu này có thay đổi đã được tự động lưu: + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft hoạt động ngoại tuyến. Không thu thập dữ liệu sử dụng, không cần tài khoản, và không xử lý đám mây trừ khi bạn thêm nhà cung cấp. + PdfCraft {c} is up to date. PdfCraft {c} đã là bản mới nhất. + PdfCraft {v} is available. PdfCraft {v} đã có sẵn. + Percentage Phần trăm + Permissions password Mật khẩu quyền hạn + Permitted actions after certifying Các hành động được phép sau khi chứng nhận + Photo Ảnh + Pick a tool in the quick bar, or type above. Chọn một công cụ trên thanh nhanh, hoặc nhập ở phía trên. + Pictures on those pages are recognized too; words already in the page's text are not added again Hình ảnh trên các trang đó cũng được nhận dạng; các từ đã có trong văn bản của trang sẽ không được thêm lại + Pin a folder to Home Ghim một thư mục vào Trang chủ + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Ghim một thư mục để liệt kê các PDF mới nhất của nó tại đây, chẳng hạn nơi máy quét hoặc ổ đám mây đồng bộ lưu chúng. + Pin a folder… Ghim một thư mục… + Plain text in reading order; pages are separated by form feeds. Văn bản thuần theo thứ tự đọc; các trang được phân tách bằng ký tự sang trang. + Planned Đã lên kế hoạch + Planned for milestone Dự kiến ở cột mốc + Points from the page's bottom-left corner. Điểm tính từ góc dưới bên trái của trang. + Portrait Dọc + Portrait Pages Trang dọc + Post Đăng + Poster Áp phích + Prefix Tiền tố + Preview Xem trước + Preview unavailable: {e} Không có bản xem trước: {e} + Preview: page {p} of {n} Xem trước: trang {p} trên {n} + Previous ({key}) Trước ({key}) + Previous / next page Trang trước / tiếp theo + Previous figure Hình trước + Previous month Tháng trước + Previous page Trang trước + Previous sheet Tờ trước + Previous view Chế độ xem trước + Print ({key}) In ({key}) + Print in grayscale In thang xám + Print page border In viền trang + Print, NextPage, PrevPage, FirstPage or LastPage Print, NextPage, PrevPage, FirstPage hoặc LastPage + Printer paper and scaling may differ. Check driver settings. Giấy in và tỷ lệ của máy in có thể khác. Hãy kiểm tra cài đặt trình điều khiển. + Printer properties Thuộc tính máy in + Printer: Máy in: + Printing allowed Cho phép in + Privacy Quyền riêng tư + Properties… Thuộc tính… + Protect Using Password Bảo vệ bằng mật khẩu + Protected: the combined file won't keep its security settings Đã bảo vệ: tệp kết hợp sẽ không giữ cài đặt bảo mật của chúng + Quality Chất lượng + Read Only Chỉ đọc + Reading colours Màu khi đọc + Ready Sẵn sàng + Reason Lý do + Recent Gần đây + Recent colours Màu gần đây + Recognize Text Nhận dạng văn bản + Recognized 1 word Đã nhận dạng 1 từ + Recognized {n} words Đã nhận dạng {n} từ + Recognized {w} words in 1 file Đã nhận dạng {w} từ trong 1 tệp + Recognized {w} words in {n} files Đã nhận dạng {w} từ trong {n} tệp + Recognizing text… file {d} of {t} Đang nhận dạng văn bản… tệp {d} trên {t} + Recognizing text… page {d} of {t} Đang nhận dạng văn bản… trang {d} trên {t} + Recommended tools Công cụ được đề xuất + Recover Khôi phục + Recover unsaved documents? Khôi phục các tài liệu chưa lưu? + Redact all Xóa tất cả nội dung đã đánh dấu + Redacted area fill colour: Màu tô vùng đã xóa: + Redaction Tool Properties Thuộc tính công cụ xóa nội dung nhạy cảm + Redaction code: Mã xóa nội dung nhạy cảm: + Reload Tải lại + Reload documents when their files change on disk Tải lại tài liệu khi tệp của chúng thay đổi trên đĩa + Remove checkmark Bỏ dấu kiểm + Remove invalid links and bookmarks Gỡ các liên kết và dấu trang không hợp lệ + Remove page Gỡ trang + Remove unreferenced named destinations Gỡ các đích đến có tên không được tham chiếu + Remove unused objects and merge identical ones Gỡ các đối tượng không dùng và gộp các đối tượng giống nhau + Remove {name} Gỡ {name} + Remove {name} from Recent Xóa {name} khỏi Gần đây + Remove {n} files Gỡ {n} tệp + Remove {n} pages Gỡ {n} trang + Reopen Closed Tab Mở lại thẻ đã đóng + Reopen the files that were open when PdfCraft last closed Mở lại các tệp đã mở khi PdfCraft đóng lần trước + Repair log Nhật ký sửa chữa + Repeat overlay text Lặp lại văn bản phủ + Replace Image… Thay thế hình ảnh… + Replace Pages Thay thế trang + Replace document colours Thay thế màu của tài liệu + Replace image Thay thế hình ảnh + Replacement text Văn bản được thay vào + Replacement: pages of {name} Thay thế: các trang của {name} + Replaces the existing one on those pages. Thay thế cái hiện có trên các trang đó. + Report Báo cáo + Report… Báo cáo… + Requires user to enter a password for: Yêu cầu người dùng nhập mật khẩu cho: + Reset columns Đặt lại các cột + Reset page size Đặt lại khổ trang + Reset to printer defaults Đặt lại về mặc định của máy in + Resize window to initial page Đổi kích thước cửa sổ theo trang ban đầu + Resolution Độ phân giải + Retain existing Giữ nguyên hiện có + Reveal in File Explorer Hiện trong File Explorer + Reveal in Finder Hiện trong Finder + Reverse closed arrow Mũi tên đóng đảo ngược + Reverse open arrow Mũi tên mở đảo ngược + Reverse pages Đảo ngược các trang + Revert changes Hoàn nguyên các thay đổi + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Hoàn nguyên về phiên bản đã lưu trước đó của “{name}”? Sau đó sẽ không thể hoàn tác các thay đổi kể từ lúc ấy. + Review History Lịch sử duyệt + Revisions Các bản sửa đổi + Right Phải + Right Edge Cạnh phải + Rotate Clockwise Xoay theo chiều kim đồng hồ + Rotate Counterclockwise Xoay ngược chiều kim đồng hồ + Rotate Pages Xoay trang + Rotate clockwise Xoay theo chiều kim đồng hồ + Rotate counterclockwise Xoay ngược chiều kim đồng hồ + Rotate view Xoay chế độ xem + Rotate view clockwise Xoay chế độ xem theo chiều kim đồng hồ + Rotate view clockwise ({key}) Xoay chế độ xem theo chiều kim đồng hồ ({key}) + Rotate view counterclockwise Xoay chế độ xem ngược chiều kim đồng hồ + Rotate: Xoay: + Rotation: Xoay: + Running action… file {d} of {t} Đang chạy hành động… tệp {d} trên {t} + Same as interface ({language}) Giống giao diện ({language}) + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. Làm sạch sẽ loại bỏ thông tin ẩn khỏi tài liệu: siêu dữ liệu, tệp đính kèm, bình luận, trường biểu mẫu (được làm phẳng), văn bản và lớp ẩn, dấu trang, liên kết, hành động và script, cùng dữ liệu riêng của ứng dụng. + Save & Close Lưu & Đóng + Save ({key}) Lưu ({key}) + Save Image As… Lưu hình ảnh thành… + Copy Image Sao chép hình ảnh + The image has been copied ({w} × {h} pixels) Đã sao chép hình ảnh ({w} × {h} điểm ảnh) + Couldn't copy the image: {e} Không thể sao chép hình ảnh: {e} + Copy Image in Original Format Sao chép hình ảnh ở định dạng gốc + The image has been copied as a {ext} file Đã sao chép hình ảnh dưới dạng tệp {ext} + The page has changed; select the image again Trang đã thay đổi; hãy chọn lại hình ảnh + Save as PDF Lưu dưới dạng PDF + Save attachment… Lưu tệp đính kèm… + Save changes to “{name}” before closing? Lưu các thay đổi của “{name}” trước khi đóng? + Save pages Lưu trang + Save tag properties Lưu thuộc tính thẻ + Save the accessibility report Lưu báo cáo trợ năng + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. Hãy lưu tài liệu sau đó: việc lưu sẽ ghi lại toàn bộ tệp để không còn dấu vết nội dung đã gỡ. + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. Hãy lưu tài liệu sau đó; việc lưu sẽ ghi lại toàn bộ tệp để không còn nội dung đã gỡ nào sót lại. + Save the spreadsheet Lưu bảng tính + Save these pages as one PDF Lưu các trang này thành một PDF + Saved measurements Đã lưu các phép đo + Saved measurements. Đã lưu các phép đo. + Scale relative to target page Tỷ lệ so với trang đích + Scan Quét + Script Name: Tên script: + Script: Nội dung script: + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Script chạy trong hộp cát không có quyền truy cập tệp hoặc mạng. Script yêu cầu mở một trang web cần có sự cho phép của bạn trước. Khi tắt JavaScript, các hàm định dạng, xác thực và tính toán chuẩn của Acrobat vẫn hoạt động. + Scroll long text Cuộn văn bản dài + Scroll tabs left Cuộn các thẻ sang trái + Scroll tabs right Cuộn các thẻ sang phải + Search comments Tìm bình luận + Search tools and commands… Tìm công cụ và lệnh… + Searchable Image (Exact) Ảnh có thể tìm kiếm (Chính xác) + Searching… {s} of {p} pages Đang tìm… {s} trên {p} trang + Searching… {s}/{p} Đang tìm… {s}/{p} + Security Bảo mật + Security settings Cài đặt bảo mật + Select (V) Chọn (V) + Select Action: Chọn hành động: + Select All Chọn tất cả + Select Icon Chọn biểu tượng + Select Trigger: Chọn trình kích hoạt: + Select a file first Hãy chọn một tệp trước + Select a file for the stamp Chọn một tệp cho con dấu + Select a password-protected file first Hãy chọn một tệp được bảo vệ bằng mật khẩu trước + Select a word Chọn một từ + Select all pages (Organize) Chọn tất cả trang (Sắp xếp) + Select at most 1000 objects at a time. Chọn tối đa 1000 đối tượng mỗi lần. + Select data files to merge Chọn các tệp dữ liệu cần gộp + Select format category: Chọn danh mục định dạng: + Select page {label} Chọn trang {label} + Select pages Chọn trang + Select the items to remove from this document. Chọn các mục cần gỡ khỏi tài liệu này. + Selected image files: {count} Tệp hình ảnh đã chọn: {count} + Selected pages Các trang đã chọn + Selection Vùng chọn + Sending to {printer}… Đang gửi đến {printer}… + Sensitivity Độ nhạy + Sent to {printer} Đã gửi đến {printer} + Sent to {printer}: {msg} Đã gửi đến {printer}: {msg} + Separator style: Kiểu dấu phân cách: + Set Alternate Text Đặt văn bản thay thế + Set Fields to Same Size Đặt các trường về cùng kích thước + Set Page Boxes Đặt các hộp trang + Set layer visibility Đặt chế độ hiển thị lớp + Set status Đặt trạng thái + Set to current page ({n}) Đặt thành trang hiện tại ({n}) + Set to zero Đặt về 0 + Shared Field Properties Thuộc tính trường dùng chung + Sheet {s} of {n} Tờ {s} trên {n} + Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift giới hạn ở góc 45 độ. Backspace xóa một đỉnh. Escape để hủy. + Shift- or Ctrl/Command-click to select several fields. Nhấn Shift hoặc Ctrl/Command khi bấm để chọn nhiều trường. + Shift-click to select several objects. Drag empty space to select an area. Nhấn Shift khi bấm để chọn nhiều đối tượng. Kéo vùng trống để chọn một khu vực. + Show Report Hiện báo cáo + Show all Hiện tất cả + Show all comments Hiện tất cả bình luận + Show certificate… Hiện chứng chỉ… + Show layer Hiện lớp + Show less Hiện ít hơn + Show password Hiện mật khẩu + Show the accessibility report Hiện báo cáo trợ năng + Show this dialog when the Checker starts Hiện hộp thoại này khi Trình kiểm tra khởi động + Show {n} more Hiện thêm {n} + Show: Hiện: + Shrink oversized pages Thu nhỏ các trang quá khổ + Sign Here Ký tại đây + Sign this field… Ký vào trường này… + Sign yourself Tự ký + Signature Details Chi tiết chữ ký + Signature is INVALID: Chữ ký KHÔNG HỢP LỆ: + Signature is valid: Chữ ký hợp lệ: + Signature panel Bảng chữ ký + Signature validity is UNKNOWN: Tính hợp lệ của chữ ký KHÔNG RÕ: + Signer's name Tên người ký + Simplified field notation: Ký hiệu trường rút gọn: + Single Page Một trang + Single Page Continuous Một trang liên tục + Single word or phrase Một từ hoặc cụm từ + Skip Rule Bỏ qua quy tắc + Skipped Đã bỏ qua + Slash Dấu gạch chéo + Smaller pages Trang nhỏ hơn + Snap to drawing Bắt dính vào bản vẽ + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Một số dấu trang không được hiển thị ở đây: bảng chỉ liệt kê một số lượng giới hạn, lồng ở độ sâu giới hạn. + Some page content couldn't be read; snapping uses the rest. Một số nội dung trang không đọc được; việc bắt dính dùng phần còn lại. + Sort by Sắp xếp theo + Sort comments by: Sắp xếp bình luận theo: + Sort items Sắp xếp các mục + Source Nguồn + Space Audit Kiểm tra dung lượng + Special Đặc biệt + Split document Tách tài liệu + Split into files… Tách thành các tệp… + Square Hình vuông + Standard Business Standard Business + Start Bắt đầu + Start Checking Bắt đầu kiểm tra + Start page number Số trang bắt đầu + Status Trạng thái + Steps Các bước + Strength: {s} Độ mạnh: {s} + Style Kiểu + Subject Chủ đề + Subset Tập con + Summarize Options Tùy chọn tóm tắt + Summary Tóm tắt + Switch to List view to change the pages it takes. Chuyển sang dạng danh sách để thay đổi các trang mà nó lấy. + Switch to two-page view first to show the cover page Hãy chuyển sang chế độ hai trang trước để hiển thị trang bìa + Tab order: move a field with its arrows. Thứ tự tab: di chuyển một trường bằng các mũi tên của nó. + Tag type Loại thẻ + Tags Thẻ + Text Color: Màu văn bản: + Text alignment: Căn chỉnh văn bản: + Text in images isn't found; recognise text (OCR) first. Văn bản trong hình ảnh không được tìm thấy; hãy nhận dạng văn bản (OCR) trước. + Text is compared word by word; the differences are shown on the new file. Văn bản được so sánh từng từ; các khác biệt được hiển thị trên tệp mới. + Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Tính năng nhận dạng văn bản chưa được cài đặt: thiếu các tệp mô hình. Hãy cài đặt lại PdfCraft, hoặc đặt PDFCRAFT_MODELS thành thư mục chứa chúng. + Text to find Văn bản cần tìm + Text to insert Văn bản cần chèn + Text used as a clipping path cannot be moved with this selection. Văn bản dùng làm đường cắt xén không thể di chuyển cùng vùng chọn này. + Text, marks and signatures become part of the page. Other comments stay editable. Văn bản, dấu và chữ ký trở thành một phần của trang. Các bình luận khác vẫn có thể chỉnh sửa. + Thai and Arabic use regular fallback fonts when available. Tiếng Thái và tiếng Ả Rập dùng phông chữ dự phòng thông thường khi có. + The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Phông chữ văn bản PDF không thể vẽ “{c}” ({code}). Hãy chọn văn bản được hỗ trợ. + The checker found 1 problem which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy 1 vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. + The checker found no problems in this document. Trình kiểm tra không tìm thấy vấn đề nào trong tài liệu này. + The checker found {n} problems which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy {n} vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. + The document now conforms to {level}; save it to keep the changes Tài liệu hiện tuân thủ {level}; hãy lưu để giữ các thay đổi + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. Tài liệu mở không cần mật khẩu; mật khẩu này cần để thay đổi các hạn chế. Mật khẩu được áp dụng khi bạn lưu. + The document's security settings don't allow copying text and images Cài đặt bảo mật của tài liệu không cho phép sao chép văn bản và hình ảnh + The document's security settings don't allow this change Cài đặt bảo mật của tài liệu không cho phép thay đổi này + The existing action will be preserved. Hành động hiện có sẽ được giữ nguyên. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Phông chữ mà PdfCraft dùng để ghi không thể vẽ “{c}” ({code}), nên nó hiển thị là ? trong mọi trình xem PDF. Văn bản vẫn được lưu. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. Khóa nằm trong kho chứng chỉ Windows, nơi có thể yêu cầu cho phép PdfCraft sử dụng nó. + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. Khóa nằm trong macOS Keychain, nơi có thể yêu cầu cho phép PdfCraft sử dụng nó. + The newest {n} of {total} PDFs {n} PDF mới nhất trong tổng số {total} + The password is only used to read the file; it isn't saved. Mật khẩu chỉ được dùng để đọc tệp; nó không được lưu. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Cài đặt riêng của trình điều khiển máy in cho lần in này: khay giấy, loại giấy, hoàn thiện và nhiều hơn nữa. + The replacement file doesn't have that many pages. Tệp thay thế không có nhiều trang như vậy. + The same password is tried on each file. Cùng một mật khẩu được thử trên từng tệp. + The signature will be invisible. Chữ ký sẽ không hiển thị. + The text is the same Văn bản giống nhau + The value is shown as typed. Giá trị được hiển thị như đã nhập. + There are no redaction marks (mark text, areas or pages first) Không có dấu đánh dấu xóa nào (hãy đánh dấu văn bản, vùng hoặc trang trước) + These files can't be read, so they won't be in the combined PDF: Không đọc được các tệp này, nên chúng sẽ không có trong PDF kết hợp: + These objects could not be moved. Select them again and try a smaller move. Không thể di chuyển các đối tượng này. Hãy chọn lại chúng và thử di chuyển ít hơn. + Thick Dày + Thickness Độ dày + Thin Mỏng + This area has too many overlapping paths; intersection snapping is limited. Vùng này có quá nhiều đường chồng lên nhau; việc bắt dính giao điểm bị giới hạn. + This certificate is in your list of trusted certificates. Chứng chỉ này nằm trong danh sách chứng chỉ tin cậy của bạn. + This certificate is not trusted. Signatures made with it show an unknown identity. Chứng chỉ này không được tin cậy. Các chữ ký tạo bằng nó hiển thị danh tính không rõ. + This certificate is trusted through a certificate above it in the chain. Chứng chỉ này được tin cậy thông qua một chứng chỉ ở cấp trên trong chuỗi. + This comment's appearance can't be changed yet. Chưa thể thay đổi diện mạo của bình luận này. + This dialog shows the details of a certificate and its chain. Hộp thoại này hiển thị chi tiết của một chứng chỉ và chuỗi chứng chỉ của nó. + This document contains {n} interactive form fields. Tài liệu này chứa {n} trường biểu mẫu tương tác. + This document has no comments to flatten Tài liệu này không có bình luận nào để làm phẳng + This document has no document-level scripts. Tài liệu này không có script cấp tài liệu. + This document has no editable structure tags. Tài liệu này không có thẻ cấu trúc có thể chỉnh sửa. + This document has no form fields to flatten Tài liệu này không có trường biểu mẫu nào để làm phẳng + This document has no form fields you can fill in Tài liệu này không có trường biểu mẫu nào bạn có thể điền + This document has no pages. Tài liệu này không có trang nào. + This document has no restrictions to lift Tài liệu này không có hạn chế nào để gỡ bỏ + This document has no signatures. Tài liệu này không có chữ ký. + This document has no tagged figures. Tài liệu này không có hình nào được gắn thẻ. + This document has no {kind} to change Tài liệu này không có {kind} để thay đổi + This document hasn't been checked yet. Tài liệu này chưa được kiểm tra. + This document is secured. Some changes are restricted by its security settings. Tài liệu này được bảo mật. Một số thay đổi bị hạn chế bởi cài đặt bảo mật của nó. + This document isn't password-protected Tài liệu này không được bảo vệ bằng mật khẩu + This drawing exceeds the snapping limit; only the first paths are used. Bản vẽ này vượt quá giới hạn bắt dính; chỉ các đường đầu tiên được sử dụng. + This field is locked. Unlock it to change its properties. Trường này đã bị khóa. Hãy mở khóa để thay đổi thuộc tính của nó. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tệp này đã thay đổi trên đĩa. Hãy tải lại để xem phiên bản mới; các thay đổi chưa lưu của bạn sẽ bị mất. + This file was damaged and has been repaired. Saving keeps the repaired version. Tệp này bị hỏng và đã được sửa chữa. Việc lưu sẽ giữ phiên bản đã sửa. + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Thư mục này hiện không khả dụng. Nó có thể đã bị di chuyển, hoặc ổ đĩa hay đồng bộ đám mây của nó đang ngoại tuyến. + This form's scripts are turned off because one ran too long. Reopen the document to run them again. Các script của biểu mẫu này đã bị tắt vì một script chạy quá lâu. Hãy mở lại tài liệu để chạy chúng lại. + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. Đây là biểu mẫu XFA động, mà PdfCraft chưa thể hiển thị. Những gì bạn thấy là trang giữ chỗ của tệp. + This measurement needs at least {n} points. Phép đo này cần ít nhất {n} điểm. + This page contains objects that cannot be selected together. Trang này chứa các đối tượng không thể chọn cùng nhau. + This page couldn't be displayed.\n{e} Không thể hiển thị trang này.\n{e} + This printer offers no settings of its own. Máy in này không có cài đặt riêng. + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Trình điều khiển của máy in này không nhận cài đặt in hai mặt từ các chương trình khác, nên cài đặt riêng của nó được áp dụng. Hãy thay đổi trong Thuộc tính… + This web address mixes letters from different alphabets, a common way to imitate another site's name. Địa chỉ web này trộn lẫn các chữ cái từ nhiều bảng chữ cái khác nhau, một cách phổ biến để giả mạo tên của trang khác. + This web address uses letters from another alphabet, which can look like familiar ones. Địa chỉ web này dùng chữ cái từ một bảng chữ cái khác, có thể trông giống các chữ quen thuộc. + Tile scale: Tỷ lệ lát: + Time Thời gian + Title Tiêu đề + To change locked fields, also set Locked to No. Để thay đổi các trường đã khóa, hãy đặt thêm Đã khóa thành Không. + To: Đến: + Today: {date} Hôm nay: {date} + Toggle fit page / fit width Chuyển đổi vừa trang / vừa chiều rộng + Tool Công cụ + Tools panel side Phía của bảng công cụ + Tooltip: Chú giải công cụ: + Top Trên + Top-level bookmarks ({n}) Dấu trang cấp cao nhất ({n}) + Total Tổng + Trust Tin cậy + Try the form as people filling it in will see it Dùng thử biểu mẫu như người điền sẽ thấy + Two-Up (Cover Page) Hai trang (Có trang bìa) + Two-Up (Facing) Hai trang (Đối diện) + Two-Up Continuous (Cover Page) Hai trang liên tục (Có trang bìa) + Two-Up Continuous (Facing) Hai trang liên tục (Đối diện) + Two-sided: Hai mặt: + Type Loại + Type text Nhập văn bản + Type the web address. Nhập địa chỉ web. + Type your {what}. Nhập {what} của bạn. + URL to submit to URL để gửi đến + Unit Đơn vị + Units Đơn vị + Unknown author Tác giả không rõ + Unlock Mở khóa + Unlock {name} Mở khóa {name} + Unlock {n} files Mở khóa {n} tệp + Unlocked: the combined file won't be password-protected Đã mở khóa: tệp kết hợp sẽ không được bảo vệ bằng mật khẩu + Unlock… Mở khóa… + Unpin Bỏ ghim + Unpin {folder} Bỏ ghim {folder} + Unplaced Chưa đặt + Unsupported measurements Các phép đo không được hỗ trợ + Up Lên + Update Cập nhật + Use 72 DPI (one point per pixel) Dùng 72 DPI (một điểm cho mỗi điểm ảnh) + Use Flate to encode streams that are not encoded Dùng Flate để mã hóa các luồng chưa được mã hóa + Use a Digital ID from a file Dùng Digital ID từ một tệp + Use custom DPI Dùng DPI tùy chỉnh + Use each image's embedded DPI; use 72 DPI when it is absent. Dùng DPI nhúng của từng hình ảnh; dùng 72 DPI khi không có. + Use image resolution Dùng độ phân giải hình ảnh + Use now Dùng ngay + Use overlay text Dùng văn bản phủ + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Được dùng khi PDF không yêu cầu bố cục hoặc mức thu phóng. Cuộn liên tục không bao giờ bắt dính giữa các trang. + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. Được dùng khi mở PDF. Chế độ khởi chạy hoặc điều khiển chỉ định rõ sẽ được ưu tiên trong phiên làm việc. + User Interface Options Tùy chọn giao diện người dùng + User password Mật khẩu người dùng + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. Có hiệu lực trong 5 năm, dùng cho chữ ký số. Các ID tự ký thường không được người khác tin cậy. + Validate all Xác thực tất cả + Value is not calculated Giá trị không được tính toán + Value is the Giá trị là + Value: Giá trị: + Verify Xác minh + Verify to see what the document needs. Hãy xác minh để biết tài liệu cần gì. + Version {v} Phiên bản {v} + Vertical Dọc + Vertical reversed Dọc đảo ngược + Vertical text cannot be moved with this selection. Văn bản dọc không thể di chuyển cùng vùng chọn này. + View revision {n} Xem bản sửa đổi {n} + View signed version Xem phiên bản đã ký + Viewport Khung nhìn + Viewport rectangle in page points (x, y, width, height) Hình chữ nhật khung nhìn theo điểm trang (x, y, chiều rộng, chiều cao) + Visible Rectangle Hình chữ nhật hiển thị + Visual Trực quan + Warnings Cảnh báo + Welcome to PdfCraft Chào mừng đến với PdfCraft + What word or phrase would you like to search for? Bạn muốn tìm từ hoặc cụm từ nào? + Whole words only Chỉ khớp toàn bộ từ + Width: Chiều rộng: + Window Options Tùy chọn cửa sổ + Windows store · Kho Windows · + Write this email? Soạn email này? + Wrong password Sai mật khẩu + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Biểu mẫu XFA: các trường và dữ liệu XFA của nó được giữ đồng bộ, nên các trình xem khác hiển thị những gì bạn điền. + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Bạn sắp áp dụng 1 dấu xóa. Văn bản, hình ảnh và bản vẽ nằm dưới các dấu, cũng như bình luận và trường biểu mẫu chồng lên chúng, sẽ bị xóa vĩnh viễn. + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Bạn sắp áp dụng {n} dấu xóa. Văn bản, hình ảnh và bản vẽ nằm dưới các dấu, cũng như bình luận và trường biểu mẫu chồng lên chúng, sẽ bị xóa vĩnh viễn. + You have version {c}. All releases are listed at {page}. Bạn đang dùng phiên bản {c}. Tất cả các bản phát hành được liệt kê tại {page}. + You have version {c}. Download the new version from its release page. Bạn đang dùng phiên bản {c}. Hãy tải phiên bản mới từ trang phát hành của nó. + Your changes will be lost if you don't save them. Các thay đổi của bạn sẽ bị mất nếu bạn không lưu. + ZIP ZIP + Zoom in Phóng to + Zoom in ({key}) Phóng to ({key}) + Zoom in / out (also pinch or {key}-scroll) Phóng to / thu nhỏ (cũng có thể chụm ngón tay hoặc {key}-cuộn) + Zoom out Thu nhỏ + Zoom out ({key}) Thu nhỏ ({key}) + already a PDF đã là PDF + and {n} more và {n} mục nữa + background nền + characters ký tự + e.g. My stamps ví dụ: Con dấu của tôi + e.g. en-US ví dụ: en-US + for images above cho hình ảnh lớn hơn + header or footer đầu trang hoặc chân trang + initials chữ viết tắt + is available có sẵn + is not available yet chưa có sẵn + just now vừa xong + yesterday hôm qua + needs an AI provider (off by default) cần nhà cung cấp AI (mặc định tắt) + no output folder was chosen chưa chọn thư mục đầu ra + no unused output name không có tên đầu ra chưa dùng + none không có + of the following fields: trong các trường sau: + AI assistant Trợ lý AI + Action Wizard Trình hướng dẫn hành động + Action Wizard… Trình hướng dẫn hành động… + Actions list Danh sách hành động + Add Bates numbering… Thêm đánh số Bates… + Add a button Thêm một nút + Add a callout Thêm một khung chú thích + Add a checkbox Thêm một hộp kiểm + Add a date field Thêm một trường ngày + Add a digital signature field Thêm một trường chữ ký số + Add a drop-down list Thêm một danh sách thả xuống + Add a list box Thêm một hộp danh sách + Add a radio button Thêm một nút radio + Add a stamp Thêm một con dấu + Add a sticky note Thêm một ghi chú dán + Add a text box Thêm một khung văn bản + Add a text field Thêm một trường văn bản + Add alternate text Thêm văn bản thay thế + Add alternate text… Thêm văn bản thay thế… + Add an image field Thêm một trường hình ảnh + Add background… Thêm nền… + Add comments Thêm bình luận + Add content Thêm nội dung + Add files to combine Thêm tệp để kết hợp + Add form components Thêm thành phần biểu mẫu + Add header & footer… Thêm đầu trang & chân trang… + Add image… Thêm hình ảnh… + Add or edit links Thêm hoặc chỉnh sửa liên kết + Add printer marks Thêm dấu máy in + Add search index Thêm chỉ mục tìm kiếm + Add text Thêm văn bản + Add watermark… Thêm hình mờ… + Advanced optimization Tối ưu hóa nâng cao + Advanced options Tùy chọn nâng cao + Apply PDF standards Áp dụng chuẩn PDF + Apply redactions… Xóa vĩnh viễn nội dung đã đánh dấu… + Archive documents Lưu trữ tài liệu + Area Diện tích + Arrow Mũi tên + ArtCraft website Trang web ArtCraft + Ask about this document Hỏi về tài liệu này + Attach a file Đính kèm một tệp + Attach file Đính kèm tệp + Audit space usage Kiểm tra dung lượng sử dụng + Automatically tag PDF Tự động gắn thẻ PDF + Background Nền + Bates numbering Đánh số Bates + Blank page Trang trống + Bring your own model (off by default) Dùng mô hình của riêng bạn (mặc định tắt) + Button Nút + Certificates Chứng chỉ + Certify (invisible signature) Chứng nhận (chữ ký ẩn) + Certify (visible signature) Chứng nhận (chữ ký hiển thị) + Change initials Đổi chữ viết tắt + Change reading options Thay đổi tùy chọn đọc + Change reading options… Thay đổi tùy chọn đọc… + Change signature Đổi chữ ký + Check for accessibility Kiểm tra trợ năng + Check for accessibility… Kiểm tra trợ năng… + Checkbox Hộp kiểm + Checkmark Dấu kiểm + Clear form Xóa dữ liệu biểu mẫu + Clear redaction marks Xóa các dấu đánh dấu + Clipboard Bảng tạm + Cloud Đám mây + Column select Chọn cột + Combine Kết hợp + Combine files Kết hợp tệp + Comment list Danh sách bình luận + Comments panel Bảng bình luận + Compare So sánh + Compare files So sánh tệp + Compare files… So sánh tệp… + Compress a PDF Nén một PDF + Console Bảng điều khiển + Convert colors Chuyển đổi màu + Copy pages Sao chép trang + Correct recognized text Sửa văn bản đã nhận dạng + Create a PDF Tạo một PDF + Create from Tạo từ + Create links from URLs Tạo liên kết từ URL + Crop pages Cắt xén trang + Cross Dấu chéo + Cut pages Cắt trang + Dark gray Xám đậm + Date Ngày + Date field Trường ngày + Detect form fields Phát hiện trường biểu mẫu + Digital signature Chữ ký số + Digitally sign Ký số + Distance Khoảng cách + Document JavaScripts JavaScript của tài liệu + Document JavaScripts… JavaScript của tài liệu… + Document actions Hành động của tài liệu + Dot Dấu chấm + Draw a cloud Vẽ một đám mây + Draw a line Vẽ một đường thẳng + Draw a polygon Vẽ một đa giác + Draw a rectangle Vẽ một hình chữ nhật + Draw an arrow Vẽ một mũi tên + Draw an oval Vẽ một hình bầu dục + Draw connected lines Vẽ các đường nối liền + Draw freehand Vẽ tự do + Drawing Bản vẽ + Drawing scale Tỷ lệ bản vẽ + Drop-down list Danh sách thả xuống + Duplicate pages Nhân bản trang + Edit a PDF Chỉnh sửa một PDF + Edit text & images Chỉnh sửa văn bản & hình ảnh + Encrypt with certificate Mã hóa bằng chứng chỉ + Enhance camera image Nâng cao chất lượng ảnh chụp từ máy ảnh + Enhance scanned file Nâng cao chất lượng tệp quét + Enter permissions password… Nhập mật khẩu quyền hạn… + Erase drawings Xóa bản vẽ + Export a PDF Xuất một PDF + Export all comments to data file… Xuất tất cả bình luận ra tệp dữ liệu… + Export all images Xuất tất cả hình ảnh + Export all images… Xuất tất cả hình ảnh… + Export data Xuất dữ liệu + Export form data… Xuất dữ liệu biểu mẫu… + Export measurements Xuất các phép đo + Export measurements as CSV Xuất các phép đo dưới dạng CSV + Export to Xuất ra + Export to PowerPoint… Xuất ra PowerPoint… + Extract Trích xuất + Extract pages Trích xuất trang + Field list Danh sách trường + Field properties Thuộc tính trường + Field properties… Thuộc tính trường… + Fields Các trường + Fill Điền + Fill & Sign: add text Điền & Ký: thêm văn bản + Fill & Sign: change initials Điền & Ký: đổi chữ viết tắt + Fill & Sign: change signature Điền & Ký: đổi chữ ký + Fill & Sign: checkmark Điền & Ký: dấu kiểm + Fill & Sign: cross Điền & Ký: dấu chéo + Fill & Sign: date Điền & Ký: ngày + Fill & Sign: dot Điền & Ký: dấu chấm + Fill & Sign: initials Điền & Ký: chữ viết tắt + Fill & Sign: line Điền & Ký: đường thẳng + Fill & Sign: remove saved initials Điền & Ký: xóa chữ viết tắt đã lưu + Fill & Sign: remove saved signature Điền & Ký: xóa chữ ký đã lưu + Fill & Sign: sign Điền & Ký: ký + Find text and redact Tìm văn bản và đánh dấu xóa + Find text and redact… Tìm văn bản và đánh dấu xóa… + Fit to width scrolling Cuộn vừa chiều rộng + Fit visible Vừa vùng hiển thị + Fix hairlines Sửa các đường siêu mảnh + Fix reading order Sửa thứ tự đọc + Flatten comments Làm phẳng bình luận + Flatten form fields Làm phẳng trường biểu mẫu + Form fields panel Bảng trường biểu mẫu + Geospatial location Vị trí địa lý + Go to page… Đi tới trang… + HTML web page Trang web HTML + Header and footer Đầu trang và chân trang + Hide all comments Ẩn tất cả bình luận + Highlight text Tô sáng văn bản + Image Hình ảnh + Image (PNG) Hình ảnh (PNG) + Image field Trường hình ảnh + Images Hình ảnh + Import comments… Nhập bình luận… + Import data Nhập dữ liệu + Import form data… Nhập dữ liệu biểu mẫu… + In multiple files Trong nhiều tệp + In this file Trong tệp này + Index Chỉ mục + Initials Chữ viết tắt + Insert Chèn + Insert pages Chèn trang + Insert text Chèn văn bản + JavaScript JavaScript + JavaScript console… Bảng điều khiển JavaScript… + Join the ArtCraft Discord Tham gia Discord của ArtCraft + Light gray Xám nhạt + Line Đường thẳng + Link Liên kết + List box Hộp danh sách + Make accessible Cải thiện khả năng tiếp cận + Manage embedded index Quản lý chỉ mục nhúng + Markup Chú thích đánh dấu + Marquee zoom Thu phóng theo vùng chọn + Measure area Đo diện tích + Measure distance Đo khoảng cách + Measure objects Đo đối tượng + Measure perimeter Đo chu vi + Measurement information Thông tin phép đo + Measurement snapping Bắt dính phép đo + Merge data files into spreadsheet… Gộp các tệp dữ liệu vào bảng tính… + Microsoft Word (.docx) Microsoft Word (.docx) + Modify page Sửa đổi trang + Multiple files Nhiều tệp + New bookmarks from structure Dấu trang mới từ cấu trúc + Open accessibility report Mở báo cáo trợ năng + Optimize scanned documents Tối ưu hóa tài liệu quét + Optimize scanned documents… Tối ưu hóa tài liệu quét… + Output preview Xem trước đầu ra + Oval Hình bầu dục + PDF/A… PDF/A… + Page grid Lưới trang + Page labels Nhãn trang + Page options Tùy chọn trang + Page transitions Hiệu ứng chuyển trang + Paste pages Dán trang + PdfCraft on GitHub PdfCraft trên GitHub + PdfCraft web page Trang web PdfCraft + Perimeter Chu vi + Pin folder to Home… Ghim thư mục vào Trang chủ… + PostScript / EPS PostScript / EPS + Preflight Kiểm tra trước in + Prepare for accessibility Chuẩn bị cho trợ năng + Prepare for distribution Chuẩn bị để phân phối + Prepare for distribution… Chuẩn bị để phân phối… + Presentation (.pptx) Bản trình bày (.pptx) + Print production Sản xuất in ấn + Protect a PDF Bảo vệ một PDF + Protect with password Bảo vệ bằng mật khẩu + Radio button Nút radio + Recognize text Nhận dạng văn bản + Recognize text in multiple files… Nhận dạng văn bản trong nhiều tệp… + Recognize text… Nhận dạng văn bản… + Rectangle Hình chữ nhật + Redact a PDF Xóa nội dung nhạy cảm trong PDF + Redact pages Xóa nội dung nhạy cảm trên trang + Redact pages… Xóa nội dung nhạy cảm trên trang… + Redact text and images Xóa nội dung nhạy cảm trong văn bản và hình ảnh + Redaction properties… Thuộc tính xóa nội dung nhạy cảm… + Reduce file size Giảm kích thước tệp + Remove all links Gỡ tất cả liên kết + Remove background Gỡ nền + Remove header & footer Gỡ đầu trang & chân trang + Remove hidden information Gỡ thông tin ẩn + Remove hidden information… Gỡ thông tin ẩn… + Remove watermark Gỡ hình mờ + Replace Thay thế + Replace text Thay thế văn bản + Reset panel layout Đặt lại bố cục bảng + Review Duyệt + Rich Text Format (.rtf) Rich Text Format (.rtf) + Rotate Xoay + Rotate pages Xoay trang + Rotate pages clockwise Xoay trang theo chiều kim đồng hồ + Rotate pages counterclockwise Xoay trang ngược chiều kim đồng hồ + Sanitize document Làm sạch tài liệu + Sanitize document… Làm sạch tài liệu… + Save as PDF/A Lưu thành PDF/A + Save as PDF/UA Lưu thành PDF/UA + Save as PDF/X Lưu thành PDF/X + Scan & OCR Quét & OCR + Security properties Thuộc tính bảo mật + Select files to compare Chọn các tệp để so sánh + Separate PDFs Các PDF riêng biệt + Set measurement scale Đặt tỷ lệ đo + Set page boxes Đặt các hộp trang + Set page boxes… Đặt các hộp trang… + Set properties Đặt thuộc tính + Show cover page in two-page view Hiện trang bìa ở chế độ hai trang + Signature Chữ ký + Signatures panel Bảng chữ ký + Single file Một tệp + Snapping Bắt dính + Split Tách + Spreadsheet (.xlsx) Bảng tính (.xlsx) + Squiggly underline Gạch chân lượn sóng + Squiggly underline text Gạch chân lượn sóng cho văn bản + Stamp Con dấu + Stamp palette Bảng chọn con dấu + Stamps Con dấu + Standards Chuẩn + Sticky note Ghi chú dán + Strikethrough text Gạch ngang văn bản + Summarize Tóm tắt + Summarize comments Tóm tắt bình luận + Switch light / dark theme Chuyển chủ đề sáng / tối + Tab order by columns Thứ tự tab theo cột + Tab order by rows Thứ tự tab theo hàng + Tab order: by columns Thứ tự tab: theo cột + Tab order: by document structure Thứ tự tab: theo cấu trúc tài liệu + Tab order: by rows Thứ tự tab: theo hàng + Tags panel Bảng thẻ + Text (plain) Văn bản (thuần) + Text box Khung văn bản + Text field Trường văn bản + Timestamp Dấu thời gian + Translate Dịch + Underline text Gạch chân văn bản + Update background… Cập nhật nền… + Update header & footer… Cập nhật đầu trang & chân trang… + Update watermark… Cập nhật hình mờ… + Use JavaScript Dùng JavaScript + Use a certificate Dùng chứng chỉ + Use guided actions Dùng hành động có hướng dẫn + Use print production Dùng sản xuất in ấn + Use system setting Dùng cài đặt hệ thống + Validate all signatures Xác thực tất cả chữ ký + Watermark Hình mờ + Move object Di chuyển đối tượng + Move objects Di chuyển các đối tượng + All tools Tất cả công cụ + Read Đọc + E-Sign Ký điện tử + Tools Công cụ + Inspector Trình thanh tra + on page {label} trên trang {label} + A font isn't embedded Một phông chữ chưa được nhúng + A {} action isn't allowed Hành động {} không được phép + A {} annotation has additional actions Chú thích {} có các hành động bổ sung + A {} annotation has no appearance stream Chú thích {} không có luồng diện mạo + A {} annotation isn't set to print, or is hidden Chú thích {} không được đặt để in, hoặc bị ẩn + Accessibility Report Báo cáo trợ năng + Add the PDFs to combine Thêm các PDF cần kết hợp + Added the first {n} PDFs in {folder} Đã thêm {n} PDF đầu tiên trong {folder} + CRL Signing Ký CRL + Certificate Signing Ký chứng chỉ + Change Author Đổi tác giả + Change Keywords Đổi từ khóa + Change Subject Đổi chủ đề + Change Title Đổi tiêu đề + Choose a digital ID. Chọn một Digital ID. + Create links Tạo liên kết + Creator Ứng dụng tạo + Cut and stack needs Two-sided: Off. Print single-sided sheets. Cắt và xếp chồng cần đặt Hai mặt: Tắt. Hãy in các tờ một mặt. + Data Encipherment Mã hóa dữ liệu + Decipher Only Chỉ giải mã + Detailed Report Báo cáo chi tiết + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile DeviceCMYK được dùng, nhưng mục đích đầu ra PDF/A không phải là hồ sơ CMYK + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile DeviceRGB được dùng, nhưng mục đích đầu ra PDF/A không phải là hồ sơ RGB + Digital Signature Chữ ký số + Document properties (⌘D) Thuộc tính tài liệu (⌘D) + Drop PDFs or folders here, or choose them. Thả PDF hoặc thư mục vào đây, hoặc chọn chúng. + Encipher Only Chỉ mã hóa + English English + Enter a URL Nhập một URL + Enter a name. Nhập một tên. + Enter a page number Nhập một số trang + Enter the field names Nhập tên các trường + Every PDF in the folder Mọi PDF trong thư mục + Every PDF in the folder and the folders inside it Mọi PDF trong thư mục và các thư mục con của nó + Field "{}" on page {p} has no description (tooltip) Trường "{}" trên trang {p} không có mô tả (chú giải công cụ) + Filename Tên tệp + Flatten Fill & Sign Làm phẳng Điền & Ký + Font (unnamed) on page {p} doesn't map its characters to Unicode Phông chữ (không tên) trên trang {p} không ánh xạ các ký tự của nó sang Unicode + Font {} on page {p} doesn't map its characters to Unicode Phông chữ {} trên trang {p} không ánh xạ các ký tự của nó sang Unicode + Form field on page {p} isn't tagged Trường biểu mẫu trên trang {p} chưa được gắn thẻ + H{n} follows an H{d}; headings should not skip levels H{n} theo sau H{d}; các đề mục không nên bỏ qua cấp + Inserted text Văn bản được chèn + Key Agreement Thỏa thuận khóa + Key Encipherment Mã hóa khóa + Margins (in) Lề (inch) + New bookmark (⌘B) Dấu trang mới (⌘B) + Next (⌘G) Tiếp theo (⌘G) + No PDFs found in {folder} Không tìm thấy PDF nào trong {folder} + No document language is set Chưa đặt ngôn ngữ cho tài liệu + No page has text: the document appears to be images of pages Không trang nào có văn bản: tài liệu có vẻ là hình ảnh của các trang + Non-Repudiation Chống chối bỏ + Only the document's owner can change its security (open it with the permissions password) Chỉ chủ sở hữu tài liệu mới có thể thay đổi bảo mật của nó (hãy mở bằng mật khẩu quyền hạn) + Open a PDF (⌘O) Mở một PDF (⌘O) + Page {p} doesn't tab in structure order Trang {p} không chuyển tab theo thứ tự cấu trúc + Page {p}: {n} untagged content item Trang {p}: {n} mục nội dung chưa gắn thẻ + Page {p}: {n} untagged content items Trang {p}: {n} mục nội dung chưa gắn thẻ + Page {} has additional actions Trang {} có các hành động bổ sung + Panel layout could not be restored: {error} Không thể khôi phục bố cục bảng: {error} + Panel move was not applied: {error} Việc di chuyển bảng chưa được áp dụng: {error} + PdfCraft can't check this signature yet: {}. PdfCraft chưa thể kiểm tra chữ ký này: {}. + Previous (⇧⌘G) Trước (⇧⌘G) + Print (⌘P) In (⌘P) + Producer Ứng dụng sản xuất + Redo {action} Làm lại {action} + Rendering… Đang kết xuất… + Report created Đã tạo báo cáo + Rev. {rev}: Certified by an unknown signer Bản sửa đổi {rev}: Được chứng nhận bởi người ký không rõ + Rev. {rev}: Certified by {name} Bản sửa đổi {rev}: Được chứng nhận bởi {name} + Rev. {rev}: Signed by an unknown signer Bản sửa đổi {rev}: Được ký bởi người ký không rõ + Rev. {rev}: Signed by {name} Bản sửa đổi {rev}: Được ký bởi {name} + Rotate view clockwise (⇧⌘+) Xoay chế độ xem theo chiều kim đồng hồ (⇧⌘+) + Rule Name Tên quy tắc + Save (M4) Lưu (M4) + Save as {level} Lưu thành {level} + Signature is invalid Chữ ký không hợp lệ + Signature is valid Chữ ký hợp lệ + Signature validity is unknown Không rõ tính hợp lệ của chữ ký + Signing time is from the clock on the signer's computer. Thời gian ký lấy từ đồng hồ trên máy tính của người ký. + Still rendering — this page is unusually complex… Vẫn đang kết xuất — trang này phức tạp bất thường… + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. Chữ ký ECDSA không ở dạng DER chuẩn (có byte thừa ở cuối, phần đệm hoặc thiếu byte dấu); nó vẫn được đọc. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. Chữ ký ECDSA là các giá trị r và s thô thay vì một chuỗi DER; nó vẫn được đọc. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. Giá trị chữ ký RSA không có độ dài bằng môđun của khóa (các byte không ở đầu bị bỏ hoặc thêm); nó vẫn được đọc. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo của chữ ký RSA không có tham số NULL (các trình ký cũ ghi như vậy); nó vẫn được đọc. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Chứng chỉ của {} không cho phép có nhiều chứng chỉ CA ở dưới nó như vậy (độ dài đường dẫn), nên nó không được dùng để bảo chứng cho {}. + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Chứng chỉ của {} không phải là chứng chỉ CA được phép cấp chứng chỉ, nên nó không được dùng để bảo chứng cho {}. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Chứng chỉ của {} không hợp lệ tại thời điểm ký, nên nó không được dùng để bảo chứng cho {}. + The checker found problems which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy các vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. + The document has been altered or corrupted since the signature was applied. Tài liệu đã bị thay đổi hoặc hỏng kể từ khi chữ ký được áp dụng. + The document has been altered or corrupted since the timestamp was applied. Tài liệu đã bị thay đổi hoặc hỏng kể từ khi dấu thời gian được áp dụng. + The document has been altered since this signature was applied in ways it does not permit ({}). Tài liệu đã bị thay đổi kể từ khi chữ ký này được áp dụng theo những cách mà nó không cho phép ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). Tài liệu đã bị thay đổi kể từ khi dấu thời gian này được áp dụng theo những cách mà nó không cho phép ({}). + The document has been modified since this signature was applied, but the changes are permitted ({}). Tài liệu đã được sửa đổi kể từ khi chữ ký này được áp dụng, nhưng các thay đổi được phép ({}). + The document has been modified since this timestamp was applied, but the changes are permitted ({}). Tài liệu đã được sửa đổi kể từ khi dấu thời gian này được áp dụng, nhưng các thay đổi được phép ({}). + The document has no tags (structure tree) Tài liệu không có thẻ (cây cấu trúc) + The document has no title Tài liệu không có tiêu đề + The document isn't marked as tagged (MarkInfo Marked) Tài liệu không được đánh dấu là có gắn thẻ (MarkInfo Marked) + The document isn't tagged, so none of its content is Tài liệu chưa được gắn thẻ, nên không nội dung nào của nó được gắn thẻ + The document's embedded revocation information shows that the signer's certificate has not been revoked. Thông tin thu hồi được nhúng trong tài liệu cho thấy chứng chỉ của người ký chưa bị thu hồi. + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. Mã thông báo dấu thời gian được nhúng hợp lệ và cơ quan cấp nó được tin cậy; thời gian tin cậy là {}. + The font {} isn't embedded Phông chữ {} chưa được nhúng + The metadata doesn't identify the file as {} Siêu dữ liệu không xác định tệp là {} + The password is incorrect. Mật khẩu không đúng. + The password must have at least 6 characters. Mật khẩu phải có ít nhất 6 ký tự. + The passwords do not match. Các mật khẩu không khớp. + The security settings don't allow screen readers to read the text Cài đặt bảo mật không cho phép trình đọc màn hình đọc văn bản + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. Thuật toán chữ ký và thuật toán băm của SignerInfo chỉ định các hàm băm khác nhau; chữ ký được tạo bằng hàm băm của SignerInfo. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. Chữ ký mang một dấu thời gian chưa được xác minh ({}): cơ quan cấp dấu thời gian không nằm trong danh sách chứng chỉ tin cậy của bạn, nên thời gian ký do người ký tuyên bố được dùng thay thế. + The signature contents are not hexadecimal. Nội dung chữ ký không phải là thập lục phân. + The signature could not be checked ({}). Không thể kiểm tra chữ ký ({}). + The signature could not be read ({}). Không thể đọc chữ ký ({}). + The signature could not be verified ({}). Không thể xác minh chữ ký ({}). + The signature has no valid byte range. Chữ ký không có dải byte hợp lệ. + The signature includes an embedded timestamp. Chữ ký bao gồm một dấu thời gian được nhúng. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. Chữ ký được mã hóa BER (độ dài không xác định) thay vì DER; nó được đọc một cách khoan dung. + The signature uses the legacy adbe.x509.rsa_sha1 format. Chữ ký dùng định dạng cũ adbe.x509.rsa_sha1. + The signature value does not match the signer's certificate: the signature is corrupt. Giá trị chữ ký không khớp với chứng chỉ của người ký: chữ ký bị hỏng. + The signature's byte range does not exclude exactly its contents. Dải byte của chữ ký không loại trừ chính xác nội dung của nó. + The signature's byte range does not match the file. Dải byte của chữ ký không khớp với tệp. + The signature's timestamp token could not be verified ({}). Không thể xác minh mã thông báo dấu thời gian của chữ ký ({}). + The signature's timestamp token does not cover the signature value. Mã thông báo dấu thời gian của chữ ký không bao phủ giá trị chữ ký. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. Nội dung được ký là một OCTET STRING dạng cấu trúc (phân đoạn), như BER cho phép; nó vẫn được đọc. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). Chứng chỉ của người ký không cho phép chữ ký số (mục đích sử dụng khóa của nó không có chữ ký số cũng không có chống chối bỏ). + The signer's certificate has been revoked ({}). Chứng chỉ của người ký đã bị thu hồi ({}). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). Chứng chỉ của người ký có các phần mở rộng quan trọng mà PdfCraft không nhận ra ({}). + The signer's certificate is not in the signature. Chứng chỉ của người ký không có trong chữ ký. + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Chứng chỉ của người ký không được cấp để ký tài liệu (mục đích sử dụng khóa mở rộng của nó không có mục đích ký tài liệu). + The signer's certificate was not valid at the time of signing. Chứng chỉ của người ký không hợp lệ tại thời điểm ký. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Danh tính của người ký không rõ vì nó chưa được đưa vào danh sách chứng chỉ tin cậy của bạn và không chứng chỉ cấp trên nào của nó là chứng chỉ tin cậy. + The signer's identity is valid. Danh tính của người ký hợp lệ. + The timestamp authority's certificate was not valid at the time of timestamping. Chứng chỉ của cơ quan cấp dấu thời gian không hợp lệ tại thời điểm đóng dấu thời gian. + The timestamp contents are not hexadecimal. Nội dung dấu thời gian không phải là thập lục phân. + The timestamp has no valid byte range. Dấu thời gian không có dải byte hợp lệ. + The timestamp token could not be read ({}). Không thể đọc mã thông báo dấu thời gian ({}). + The timestamp token is valid and its authority is trusted. Mã thông báo dấu thời gian hợp lệ và cơ quan cấp nó được tin cậy. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. Mã thông báo dấu thời gian hợp lệ, nhưng cơ quan cấp dấu thời gian không nằm trong danh sách chứng chỉ tin cậy của bạn. + The timestamp's byte range does not exclude exactly its contents. Dải byte của dấu thời gian không loại trừ chính xác nội dung của nó. + The timestamp's byte range does not match the file. Dải byte của dấu thời gian không khớp với tệp. + The window shows the file name, not the title (Initial View > Show) Cửa sổ hiển thị tên tệp, không phải tiêu đề (Chế độ xem ban đầu > Hiện) + There is no folder to save the digital ID in. Không có thư mục nào để lưu Digital ID. + This document has not been modified since this signature was applied. Tài liệu này chưa bị sửa đổi kể từ khi chữ ký này được áp dụng. + This document has not been modified since this timestamp was applied. Tài liệu này chưa bị sửa đổi kể từ khi dấu thời gian này được áp dụng. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Chữ ký này dùng RIPEMD-160, một thuật toán băm 160 bit cũ không còn được khuyến nghị cho chữ ký. Nó vẫn xác thực được, nhưng không nên dựa vào nó. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Chữ ký này dùng SHA-1, một thuật toán băm yếu: các va chạm trong nó đã được chứng minh. Nó vẫn xác thực được vì nhiều tài liệu được ký từ nhiều năm trước dùng nó, nhưng không nên dựa vào nó. + Undo {action} Hoàn tác {action} + Unsigned signature field Trường chữ ký chưa ký + Unsigned signature field: {name} Trường chữ ký chưa ký: {name} + Zoom in (⌘+) Phóng to (⌘+) + Zoom in / out (also pinch or ⌘-scroll) Phóng to / thu nhỏ (cũng có thể chụm ngón tay hoặc ⌘-cuộn) + Zoom out (⌘−) Thu nhỏ (⌘−) + alternate text hides an annotation inside it văn bản thay thế che khuất một chú thích bên trong nó + alternate text on an element with no page content văn bản thay thế trên phần tử không có nội dung trang + comments bình luận + document security store kho bảo mật tài liệu + document structure cấu trúc tài liệu + form fill điền biểu mẫu + its alternate text is inside another element's and will never be read văn bản thay thế của nó nằm trong văn bản thay thế của phần tử khác và sẽ không bao giờ được đọc + links liên kết + metadata siêu dữ liệu + no alternate text không có văn bản thay thế + no header cells (TH) không có ô tiêu đề (TH) + no summary không có tóm tắt + not in a TR không nằm trong TR + not in a Table, THead, TBody or TFoot không nằm trong Table, THead, TBody hoặc TFoot + not in an L (list) không nằm trong L (danh sách) + not in an LI (list item) không nằm trong LI (mục danh sách) + other changes các thay đổi khác + page content nội dung trang + page {n} is out of range (1–{count}) trang {n} nằm ngoài phạm vi (1–{count}) + pages added or removed trang được thêm hoặc xóa + rows have between {min} and {max} columns các hàng có từ {min} đến {max} cột + the first heading is an H{n}; headings should not skip levels đề mục đầu tiên là H{n}; các đề mục không nên bỏ qua cấp + the signed version could not be read không thể đọc phiên bản đã ký + {n} pages and no bookmarks {n} trang và không có dấu trang + {page} is not a page number or label {page} không phải là số trang hoặc nhãn + {ty} element on page {p}: {what} Phần tử {ty} trên trang {p}: {what} + {ty} element: {what} Phần tử {ty}: {what} + {who}, certificate issued by an unknown issuer. {who}, chứng chỉ do tổ chức không rõ cấp. + {who}, certificate issued by {issuer}. {who}, chứng chỉ do {issuer} cấp. + {} annotation on page {p} isn't tagged Chú thích {} trên trang {p} chưa được gắn thẻ + {} annotations aren't allowed Chú thích {} không được phép + … and {n} more … và {n} mục nữa +@plural Delete {n} field|Delete {n} fields Xóa {n} trường +@plural {n} byte|{n} bytes {n} byte +@plural {n} image optimized|{n} images optimized Đã tối ưu hóa {n} hình ảnh +@plural {n} item discarded|{n} items discarded Đã loại bỏ {n} mục +@plural {n} pixel|{n} pixels {n} điểm ảnh +action wizard Start Bắt đầu +comment menu Edit Sửa + A button Một nút + A diagonal CONFIDENTIAL watermark on every page. Hình mờ CONFIDENTIAL nằm chéo trên mọi trang. + A document needs at least one page: copy instead Một tài liệu cần ít nhất một trang: hãy sao chép thay vào đó + A form field has additional actions Một trường biểu mẫu có các hành động bổ sung + A link Một liên kết + A script Một script + A selected field has no widget on any page, so the fields can't be edited together. Một trường đã chọn không có widget trên trang nào, nên các trường không thể được chỉnh sửa cùng nhau. + A stream refers to an external file Một luồng tham chiếu đến tệp bên ngoài + A stream uses LZW compression Một luồng dùng nén LZW + A summary describes the table's purpose and layout. Add a Summary attribute to each table. Phần tóm tắt mô tả mục đích và bố cục của bảng. Hãy thêm thuộc tính Summary cho mỗi bảng. + About Giới thiệu + Accepted Đã chấp nhận + Accessibility permission flag Cờ quyền trợ năng + Accessibility permission flag is set Cờ quyền trợ năng đã được đặt + Add Page Numbers Thêm số trang + Add arrow Thêm mũi tên + Add background Thêm nền + Add bookmark Thêm dấu trang + Add callout Thêm khung chú thích + Add checkmark Thêm dấu kiểm + Add cloud Thêm đám mây + Add connected lines Thêm các đường nối liền + Add cross Thêm dấu chéo + Add dot Thêm dấu chấm + Add drawing Thêm bản vẽ + Add field Thêm trường + Add file attachment Thêm tệp đính kèm + Add footer Thêm chân trang + Add header Thêm đầu trang + Add header & footer Thêm đầu trang & chân trang + Add highlight Thêm tô sáng + Add image Thêm hình ảnh + Add inserted text Thêm văn bản được chèn + Add line Thêm đường thẳng + Add link Thêm liên kết + Add oval Thêm hình bầu dục + Add polygon Thêm đa giác + Add rectangle Thêm hình chữ nhật + Add redaction mark Thêm dấu đánh dấu xóa + Add signature Thêm chữ ký + Add squiggly underline Thêm gạch chân lượn sóng + Add stamp Thêm con dấu + Add sticky note Thêm ghi chú dán + Add strikethrough Thêm gạch ngang + Add text box Thêm khung văn bản + Add underline Thêm gạch chân + Add watermark Thêm hình mờ + Add {what} Thêm {what} + Added the first {n} files in {folder} Đã thêm {n} tệp đầu tiên trong {folder} + Adding images arrives on the web with file pickers for images Việc thêm hình ảnh sẽ có trên web cùng với bộ chọn tệp hình ảnh + Algorithm Thuật toán + Align fields Căn chỉnh các trường + All annotations are tagged Tất cả chú thích đã được gắn thẻ + All form fields are tagged Tất cả trường biểu mẫu đã được gắn thẻ + All form fields have description Tất cả trường biểu mẫu đều có mô tả + All multimedia objects are tagged Tất cả đối tượng đa phương tiện đã được gắn thẻ + All page content is tagged Tất cả nội dung trang đã được gắn thẻ + Allowed Được phép + Alternate Text Văn bản thay thế + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. Văn bản thay thế bên trong phần tử đã có văn bản thay thế sẽ không bao giờ được đọc. Hãy gỡ văn bản thay thế bên trong. + Alternate text must be associated with some content Văn bản thay thế phải được liên kết với một nội dung nào đó + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. Văn bản thay thế trên phần tử chứa liên kết hoặc trường sẽ che chú thích đó khỏi công nghệ hỗ trợ. Hãy chuyển văn bản thay thế. + Alternate text on an element without page content is never reached. Remove it or attach the element to content. Văn bản thay thế trên phần tử không có nội dung trang sẽ không bao giờ được truy cập tới. Hãy gỡ nó hoặc gắn phần tử vào nội dung. + Alternate text should not hide annotation Văn bản thay thế không nên che chú thích + Alternate text that will never be read Văn bản thay thế sẽ không bao giờ được đọc + An action is already running Một hành động đang chạy + An embedded file has no AFRelationship Một tệp nhúng không có AFRelationship + An image asks to be interpolated Một hình ảnh yêu cầu được nội suy + An image has alternates or OPI information Một hình ảnh có bản thay thế hoặc thông tin OPI + Animation that flashes can cause seizures. Check scripts and media for flicker. Hoạt ảnh nhấp nháy có thể gây co giật. Hãy kiểm tra script và phương tiện xem có nhấp nháy không. + Another file dialog is still open. Finish with it first. Một hộp thoại tệp khác vẫn đang mở. Hãy hoàn tất nó trước. + Application Ứng dụng + Applied 1 redaction mark. Save to remove the content from the file. Đã áp dụng 1 dấu xóa. Hãy lưu để loại bỏ nội dung khỏi tệp. + Applied {n} redaction marks. Save to remove the content from the file. Đã áp dụng {n} dấu xóa. Hãy lưu để loại bỏ nội dung khỏi tệp. + Appropriate nesting Lồng ghép phù hợp + Arbitrary Mask Mặt nạ tùy ý + Associated with content Được liên kết với nội dung + At least one signature has problems. Ít nhất một chữ ký có vấn đề. + At least one signature is invalid. Ít nhất một chữ ký không hợp lệ. + Attachment Tệp đính kèm + Basic constraints Ràng buộc cơ bản + Beveled Vát cạnh + Black Đen + Black on white Đen trên nền trắng + Blue Xanh dương + Bookmarks are present in large documents Dấu trang có mặt trong các tài liệu lớn + Both Cả hai + Button Properties Thuộc tính nút + Button Style: Kiểu nút: + Calculate Tính toán + Callout Khung chú thích + Cancelled Đã hủy + Center Footer Text Văn bản chân trang giữa + Center Header Text Văn bản đầu trang giữa + Center horizontally Căn giữa theo chiều ngang + Center vertically Căn giữa theo chiều dọc + Certificate Viewer Trình xem chứng chỉ + Certified by {by} Được chứng nhận bởi {by} + Change comment properties Thay đổi thuộc tính bình luận + Change document properties Thay đổi thuộc tính tài liệu + Change field properties Thay đổi thuộc tính trường + Change initial view Thay đổi chế độ xem ban đầu + Change link properties Thay đổi thuộc tính liên kết + Change tag properties Thay đổi thuộc tính thẻ + Change {key} Thay đổi {key} + Changing the document Thay đổi tài liệu + Character encoding Mã hóa ký tự + Character limit: Giới hạn ký tự: + Check Đánh dấu chọn + Check Box Properties Thuộc tính hộp kiểm + Check Box Style: Kiểu hộp kiểm: + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Hãy kiểm tra rằng các thẻ được đọc theo thứ tự mà người đọc nhìn thấy được sẽ đọc. Dùng công cụ Thứ tự đọc hoặc bảng Thẻ để sửa. + CheckBox CheckBox + Checked Đã đánh dấu chọn + Choose a folder for the split files Chọn thư mục cho các tệp đã tách + Choose a folder on this computer to pin Chọn một thư mục trên máy tính này để ghim + Click on the page to add a {tool}, or drag to set its size Bấm vào trang để thêm {tool}, hoặc kéo để đặt kích thước + Click text or an image to edit it Bấm vào văn bản hoặc hình ảnh để chỉnh sửa + Color Images Ảnh màu + Color Spaces Không gian màu + Color contrast Độ tương phản màu + Comb of characters Dạng lược gồm các ký tự + Combo Combo + Coming in milestone {m}. Items marked Ready work today. Sẽ có ở cột mốc {m}. Các mục được đánh dấu Sẵn sàng hoạt động ngay hôm nay. + Command Lệnh + Commenting Bình luận + Comments and markups Bình luận và chú thích đánh dấu + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. Bình luận, liên kết và trường phải nằm trong cây thẻ để được đọc to. Hãy gắn thẻ cho các chú thích. + Completed Đã hoàn thành + Configure a Digital ID for Signing Cấu hình Digital ID để ký + Connected lines Các đường nối liền + Content Streams Luồng nội dung + Content copying Sao chép nội dung + Content copying for accessibility Sao chép nội dung cho trợ năng + Contributors Người đóng góp + Copied {what} Đã sao chép {what} + Copy or cut pages first Hãy sao chép hoặc cắt trang trước + Could not save: {e} Không thể lưu: {e} + Couldn't add {name}: {e} Không thể thêm {name}: {e} + Couldn't combine files: {e} Không thể kết hợp các tệp: {e} + Couldn't create a PDF: {e} Không thể tạo PDF: {e} + Couldn't download {name}: {e} Không thể tải xuống {name}: {e} + Couldn't extract pages: {e} Không thể trích xuất trang: {e} + Couldn't fit the visible content: {e} Không thể làm vừa nội dung hiển thị: {e} + Couldn't follow linked PDF: {reason} Không thể theo liên kết đến PDF: {reason} + Couldn't import the signature image: {e} Không thể nhập hình ảnh chữ ký: {e} + Couldn't open revision {n}: {e} Không thể mở bản sửa đổi {n}: {e} + Couldn't open the result: {e} Không thể mở kết quả: {e} + Couldn't open the signed version: {e} Không thể mở phiên bản đã ký: {e} + Couldn't open {name}: {e} Không thể mở {name}: {e} + Couldn't optimize the file Không thể tối ưu hóa tệp + Couldn't optimize the file: {e} Không thể tối ưu hóa tệp: {e} + Couldn't print from the browser ({e}); downloaded {name} to print instead Không thể in từ trình duyệt ({e}); đã tải {name} xuống để in thay thế + Couldn't read {name}: {e} Không thể đọc {name}: {e} + Couldn't recover {name}: {e} Không thể khôi phục {name}: {e} + Couldn't reload {name}: {e} Không thể tải lại {name}: {e} + Couldn't save the image: {e} Không thể lưu hình ảnh: {e} + Couldn't save {name}: {e} Không thể lưu {name}: {e} + Couldn't save: {e} Không thể lưu: {e} + Couldn't show the file picker. Please try again. Không thể hiển thị bộ chọn tệp. Vui lòng thử lại. + Couldn't split: {e} Không thể tách: {e} + Couldn't summarize comments: {e} Không thể tóm tắt bình luận: {e} + Couldn't take the snapshot: {e} Không thể chụp ảnh nhanh: {e} + Couldn't unlock {name}: {e} Không thể mở khóa {name}: {e} + Couldn't use the photo: {e} Không thể dùng ảnh: {e} + Couldn't use {name}: {e} Không thể dùng {name}: {e} + Couldn't write {name}: {e} Không thể ghi {name}: {e} + Couldn't write {path}: {e} Không thể ghi {path}: {e} + Create link Tạo liên kết + Created 1 link Đã tạo 1 liên kết + Created a PDF from the first {n} files Đã tạo một PDF từ {n} tệp đầu tiên + Created a comment summary Đã tạo bản tóm tắt bình luận + Created {n} links Đã tạo {n} liên kết + Credit Cards Thẻ tín dụng + Crop page Cắt xén trang + Custom font Phông chữ tùy chỉnh + Custom stamps arrive on the web with file pickers for images Con dấu tùy chỉnh sẽ có trên web cùng với bộ chọn tệp hình ảnh + Cut {what} Đã cắt {what} + Dark gray theme Chủ đề xám đậm + Dashed Nét đứt + Dates Ngày tháng + Delete bookmark Xóa dấu trang + Delete content Xóa nội dung + Delete document JavaScript Xóa JavaScript của tài liệu + Delete field Xóa trường + Delete image Xóa hình ảnh + Delete link Xóa liên kết + Delete page Xóa trang + Delete selected fields Xóa các trường đã chọn + Deleted Đã xóa + Detected 1 form field Đã phát hiện 1 trường biểu mẫu + Detected {n} form fields Đã phát hiện {n} trường biểu mẫu + Device colour is used, but there is no PDF/A output intent Màu thiết bị được dùng, nhưng không có mục đích đầu ra PDF/A + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) DeviceCMYK được dùng: cần có mục đích đầu ra CMYK (không được thêm tự động) + Diagnostic details Chi tiết chẩn đoán + Digital Signature Properties Thuộc tính chữ ký số + Discard all comments, forms and multimedia Loại bỏ tất cả bình luận, biểu mẫu và đa phương tiện + Discard all links, actions and JavaScript Loại bỏ tất cả liên kết, hành động và JavaScript + Discard all object data (file attachments) Loại bỏ tất cả dữ liệu đối tượng (tệp đính kèm) + Discard bookmarks Loại bỏ dấu trang + Discard document information and metadata Loại bỏ thông tin tài liệu và siêu dữ liệu + Discard hidden layer content Loại bỏ nội dung lớp ẩn + Discard hidden text Loại bỏ văn bản ẩn + Discard private data of other applications Loại bỏ dữ liệu riêng của các ứng dụng khác + Distribute fields Phân bố các trường + Document Overhead Chi phí cấu trúc tài liệu + Document assembly Lắp ráp tài liệu + Document has appropriate color contrast Tài liệu có độ tương phản màu phù hợp + Document is not image-only PDF Tài liệu không phải PDF chỉ có hình ảnh + Document is tagged PDF Tài liệu là PDF có gắn thẻ + Document sanitized. Save to finish: saving rewrites the whole file. Đã làm sạch tài liệu. Hãy lưu để hoàn tất: việc lưu sẽ ghi lại toàn bộ tệp. + Document structure provides a logical reading order Cấu trúc tài liệu cung cấp thứ tự đọc hợp lý + Document title is showing in title bar Tiêu đề tài liệu đang hiển thị trên thanh tiêu đề + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. Tài liệu có hơn 20 trang cần dấu trang để điều hướng. Hãy thêm dấu trang cho các phần chính. + Double-click Bấm đúp + Downloaded {name} Đã tải xuống {name} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Việc tải tệp đính kèm trên web sẽ có cùng M3.10 (đã sẵn sàng {n} byte) + Drag a rectangle around the area to copy Kéo một hình chữ nhật quanh vùng cần sao chép + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes Kéo một hình chữ nhật trên trang để cắt xén; bấm đúp vào trang để mở Đặt các hộp trang + Drag a rectangle to create a link; double-click a link to edit it Kéo một hình chữ nhật để tạo liên kết; bấm đúp vào liên kết để chỉnh sửa + Drag the corners onto the document's edges, then choose Enhance page. Kéo các góc vào cạnh của tài liệu, rồi chọn Nâng cao chất lượng trang. + Drag to draw the area where the signature should appear. Kéo để vẽ vùng nơi chữ ký sẽ xuất hiện. + Dropdown Properties Thuộc tính danh sách thả xuống + Duplicate field Nhân bản trường + Duplicate page Nhân bản trang + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. Mỗi trường cần có chú giải công cụ nói rõ cần nhập gì. Hãy thêm trong Thuộc tính trường > Chung > Chú giải công cụ. + Edit comment Chỉnh sửa bình luận + Edit content Chỉnh sửa nội dung + Edit document JavaScript Chỉnh sửa JavaScript của tài liệu + Edit fields Chỉnh sửa các trường + Edit script of {name} Chỉnh sửa script của {name} + Edit text Chỉnh sửa văn bản + Editing Chỉnh sửa + Email Addresses Địa chỉ email + Embedded Files Tệp nhúng + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Các tệp nhúng phải tự là tệp PDF/A (dùng PDF/A-3 cho các tệp khác) + Encryption Mã hóa + Endpoint Điểm cuối + Erase Tẩy + Eraser Cục tẩy + Even pages Các trang chẵn + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. Mọi thứ trên trang phải được gắn thẻ hoặc đánh dấu là phần trang trí (trang trí, đầu trang, chân trang). Hãy gắn thẻ cho nội dung chưa gắn thẻ. + Execute a menu item Thực thi một mục trình đơn + Export certificate Xuất chứng chỉ + Exported to {path} Đã xuất ra {path} + Extended Graphics States Trạng thái đồ họa mở rộng + Field Trường + Field descriptions Mô tả trường + Figures alternate text Văn bản thay thế của hình + Figures require alternate text Hình yêu cầu văn bản thay thế + File attachments Tệp đính kèm + Files are combined in this order. Leave Pages empty to take every page. Các tệp được kết hợp theo thứ tự này. Để trống mục Trang để lấy mọi trang. + Fill in {name} Điền {name} + Filling of form fields Điền trường biểu mẫu + Flatten Làm phẳng + Flip image Lật hình ảnh + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. Phông chữ phải ánh xạ ký tự của chúng sang Unicode để văn bản có thể được đọc và sao chép. Hãy sửa phông chữ hoặc tạo lại PDF. + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. Trường biểu mẫu phải nằm trong cây thẻ (thẻ Form) để được đọc to. Hãy gắn thẻ cho các trường biểu mẫu. + Formulas and similar non-text elements need alternate text. Add alternate text to them. Công thức và các phần tử không phải văn bản tương tự cần có văn bản thay thế. Hãy thêm văn bản thay thế cho chúng. + Gray Xám + Grayscale Images Ảnh thang xám + Green Xanh lá + Green on black Xanh lá trên nền đen + H / Space (hold) H / Space (giữ) + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. Ô tiêu đề (TH) cho người nghe biết mỗi cột hoặc hàng chứa gì. Hãy đánh dấu các ô tiêu đề. + Headers Tiêu đề đầu mục + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. Cấp đề mục không nên bị bỏ qua (H1 theo sau là H3). Hãy gắn thẻ lại các đề mục. + Headings Đề mục + Hidden layers Lớp ẩn + Hidden text Văn bản ẩn + Hide a field Ẩn một trường + Hides annotation Che chú thích + High Cao + Home / End Home / End + Horizontally Theo chiều ngang + Image-only PDF PDF chỉ có hình ảnh + Import {name} Nhập {name} + Importing data arrives on the web with file pickers for data files Việc nhập dữ liệu sẽ có trên web cùng với bộ chọn tệp dữ liệu + Incorrect password. Try again. Mật khẩu không đúng. Hãy thử lại. + Insert pages from {name} Chèn trang từ {name} + Insert pages from {name} — 1 field renamed Chèn trang từ {name} — 1 trường được đổi tên + Insert pages from {name} — fields renamed Chèn trang từ {name} — các trường được đổi tên + Inserted Đã chèn + Inset Lõm + Intended usage Mục đích sử dụng + Intersection Giao điểm + Invert Đảo màu + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). Các bảng không đều gây khó cho việc điều hướng ô. Hãy cho mọi hàng cùng số ô (tính cả các ô gộp). + Issued by Cấp bởi + Issued to Cấp cho + Issuer Tổ chức cấp + Key usage Mục đích sử dụng khóa + Keywords Từ khóa + LI must be a child of L LI phải là con của L + Landscape pages Các trang ngang + Lbl and LBody Lbl và LBody + Lbl and LBody must be children of LI Lbl và LBody phải là con của LI + Left Footer Text Văn bản chân trang trái + Left Header Text Văn bản đầu trang trái + Light blue Xanh lam nhạt + Links, actions and JavaScripts Liên kết, hành động và JavaScript + List Danh sách + List Box Properties Thuộc tính hộp danh sách + List items Mục danh sách + List items belong in a list. Fix the list's tags. Các mục danh sách phải nằm trong một danh sách. Hãy sửa thẻ của danh sách. + List labels and bodies belong in a list item. Fix the list's tags. Nhãn và nội dung danh sách phải nằm trong một mục danh sách. Hãy sửa thẻ của danh sách. + Lists Danh sách + Load document font Tải phông chữ của tài liệu + Local cross-PDF links require the desktop app Liên kết PDF cục bộ giữa các tệp yêu cầu ứng dụng máy tính + Lock comment Khóa bình luận + Logical Reading Order Thứ tự đọc hợp lý + Low Thấp + Make scans searchable, then make them smaller. Làm cho bản quét có thể tìm kiếm được, rồi làm cho chúng nhỏ hơn. + Mark all Đánh dấu tất cả + Mark as Confidential Đánh dấu là Bí mật + Mark differences Đánh dấu khác biệt + Mark figure as decorative Đánh dấu hình là trang trí + Mark for redaction Đánh dấu để xóa + Mark pages for redaction Đánh dấu trang để xóa + Match field sizes Khớp kích thước trường + Maximum Tối đa + Measuring tool Công cụ đo + Merged 1 file into {path} Đã gộp 1 tệp vào {path} + Merged {n} files into {path} Đã gộp {n} tệp vào {path} + Merging data files needs the desktop app Việc gộp tệp dữ liệu yêu cầu ứng dụng máy tính + Metadata Siêu dữ liệu + Midpoint Điểm giữa + Minimum Tối thiểu + Models Mô hình + Mouse Down Nhấn chuột + Mouse Enter Chuột vào + Mouse Exit Chuột ra + Mouse Up Nhả chuột + Move bookmark Di chuyển dấu trang + Move comment Di chuyển bình luận + Move image Di chuyển hình ảnh + Move page Di chuyển trang + Move pages Di chuyển các trang + Named Destinations Đích đến có tên + Navigation links Liên kết điều hướng + Navigation links are not repetitive Liên kết điều hướng không lặp lại + Nested alternate text Văn bản thay thế lồng nhau + New {tool} comments will look like this one Các bình luận {tool} mới sẽ trông giống bình luận này + No PDFs, images or text files found in {folder} Không tìm thấy PDF, hình ảnh hoặc tệp văn bản nào trong {folder} + No form fields were detected Không phát hiện trường biểu mẫu nào + No inaccessible scripts Không có script không thể tiếp cận + No links to remove Không có liên kết nào để gỡ + No pages match those choices Không có trang nào khớp với các lựa chọn đó + No unlinked web addresses were found in the text Không tìm thấy địa chỉ web nào chưa được liên kết trong văn bản + None of those files can be made into a PDF; use PDFs, images or .txt files Không tệp nào trong số đó có thể chuyển thành PDF; hãy dùng PDF, hình ảnh hoặc tệp .txt + Note Ghi chú + Number every page in the footer. Đánh số mọi trang ở chân trang. + Odd pages Các trang lẻ + On Blur Khi mất tiêu điểm + On Focus Khi có tiêu điểm + On the web, open or drop an image to convert it Trên web, hãy mở hoặc thả một hình ảnh để chuyển đổi + Open a web link Mở một liên kết web + Opened with Mở bằng + Opening the browser's print dialog… Đang mở hộp thoại in của trình duyệt… + Optimization cancelled Đã hủy tối ưu hóa + Optimization is already running Việc tối ưu hóa đang chạy + Optimize Scanned Documents Tối ưu hóa tài liệu quét + Options Tùy chọn + Orange Cam + Other elements alternate text Văn bản thay thế của các phần tử khác + Other elements that require alternate text Các phần tử khác cần văn bản thay thế + Outline Đường bao + PDF producer Ứng dụng sản xuất PDF + PDF version Phiên bản PDF + Page Content Nội dung trang + Page Range Phạm vi trang + Page does not require timed responses Trang không yêu cầu phản hồi có giới hạn thời gian + Page thumbnails Hình thu nhỏ trang + Page will not cause screen flicker Trang sẽ không gây nhấp nháy màn hình + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. Các trang chỉ là hình chụp của văn bản không thể được đọc to hoặc tìm kiếm. Hãy nhận dạng văn bản (OCR) để thêm lớp văn bản. + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. Các trang có liên kết, bình luận hoặc trường nên chuyển tab theo thứ tự cấu trúc. Cách sửa đặt thứ tự tab của mỗi trang thành Dùng cấu trúc tài liệu. + Passed Đạt + Password protection will be applied when you save Bảo vệ bằng mật khẩu sẽ được áp dụng khi bạn lưu + Path Đường dẫn + PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. PdfCraft đang vẽ mà không có bộ xử lý đồ họa, nên có thể chậm. Cập nhật trình điều khiển đồ họa có thể giúp ích. + People may need more time to respond. Check that nothing has a time limit. Mọi người có thể cần thêm thời gian để phản hồi. Hãy kiểm tra rằng không có gì bị giới hạn thời gian. + Phone Number Số điện thoại + Phone Numbers Số điện thoại + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. Hình ảnh cần văn bản thay thế mô tả chúng, hoặc phải được đánh dấu là trang trí. Hãy thêm văn bản thay thế cho mỗi hình. + Pink Hồng + Pinned folders need the desktop app Thư mục được ghim yêu cầu ứng dụng máy tính + Pinned {folder} to Home Đã ghim {folder} vào Trang chủ + Place saved {what} Đặt {what} đã lưu + Planned for milestone {m} — open to see what it will include Dự kiến ở cột mốc {m} — mở để xem nó sẽ bao gồm những gì + Planned · {m} Đã lên kế hoạch · {m} + Polygon Đa giác + Polyline Đa tuyến + Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Chế độ di động đang tắt: không thể ghi vào {folder} ({error}). Cài đặt được lưu trong thư mục người dùng của bạn thay thế. + Portrait pages Các trang dọc + Position Vị trí + PostScript XObjects aren't allowed PostScript XObject không được phép + Prepare for Distribution Chuẩn bị để phân phối + Preparing sheet {done} of {total} for printing… Đang chuẩn bị tờ {done} trên {total} để in… + Preparing {n} sheets for printing… Đang chuẩn bị {n} tờ để in… + Primary language Ngôn ngữ chính + Printing In ấn + Private data of other applications Dữ liệu riêng của các ứng dụng khác + Public key Khóa công khai + Purple Tím + PushButton PushButton + Radio Radio + Radio Button Properties Thuộc tính nút radio + Re-type Password Nhập lại mật khẩu + Red Đỏ + Redid {label} Đã làm lại {label} + Regularity Tính đều đặn + Rejected Đã từ chối + Reliable character encoding is provided Có mã hóa ký tự đáng tin cậy + Remove hidden information and flatten comments and form fields, so recipients see only the pages. Gỡ thông tin ẩn và làm phẳng bình luận cùng trường biểu mẫu, để người nhận chỉ thấy các trang. + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Gỡ thông tin ẩn, siêu dữ liệu, script và nội dung nhúng, rồi ghi lại các tệp. + Remove redaction marks Gỡ các dấu đánh dấu + Remove saved {what} Xóa {what} đã lưu + Removed 1 hidden item. Save to remove them from the file. Đã gỡ 1 mục ẩn. Hãy lưu để xóa chúng khỏi tệp. + Removed 1 link Đã gỡ 1 liên kết + Removed {n} hidden items. Save to remove them from the file. Đã gỡ {n} mục ẩn. Hãy lưu để xóa chúng khỏi tệp. + Removed {n} links Đã gỡ {n} liên kết + Rename bookmark Đổi tên dấu trang + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Kết xuất: hayro (khởi tạo) · Giao diện: egui · Biểu tượng: Lucide (ISC) · Phông chữ: Inter, JetBrains Mono, Dancing Script (OFL) + Repairs Sửa chữa + Repeated navigation links should be skippable. Check links that appear on every page. Các liên kết điều hướng lặp lại nên có thể bỏ qua được. Hãy kiểm tra các liên kết xuất hiện trên mọi trang. + Replace page Thay thế trang + Replace pages Thay thế trang + Replaced Đã thay thế + Replacing images arrives on the web with image file pickers Việc thay thế hình ảnh sẽ có trên web cùng với bộ chọn tệp hình ảnh + Replacing images on page {p} arrives on the web with image pickers ({i}) Việc thay thế hình ảnh trên trang {p} sẽ có trên web cùng với bộ chọn hình ảnh ({i}) + Report Options Tùy chọn báo cáo + Reset a form Đặt lại một biểu mẫu + Resize comment Đổi kích thước bình luận + Restrictions Hạn chế + Restrictions lifted Đã gỡ các hạn chế + Reverted to the last saved version Đã hoàn nguyên về phiên bản đã lưu gần nhất + Right Footer Text Văn bản chân trang phải + Right Header Text Văn bản đầu trang phải + Rotate image Xoay hình ảnh + Rotate page Xoay trang + Rotate page {label} clockwise Xoay trang {label} theo chiều kim đồng hồ + Rotate page {label} counterclockwise Xoay trang {label} ngược chiều kim đồng hồ + Rows Hàng + Run Chạy + Run JavaScript Chạy JavaScript + Run a JavaScript Chạy một JavaScript + Run form script Chạy script biểu mẫu + SHA-1 digest Giá trị băm SHA-1 + SHA-256 digest Giá trị băm SHA-256 + Sanitize Làm sạch + Sanitize Documents Làm sạch tài liệu + Save as PDF/A-1b Lưu thành PDF/A-1b + Save as PDF/A-2b Lưu thành PDF/A-2b + Save as PDF/A-3b Lưu thành PDF/A-3b + Save image Lưu hình ảnh + Save or revert changes before selecting another tag. Hãy lưu hoặc hoàn nguyên các thay đổi trước khi chọn thẻ khác. + Saved the print-ready PDF to {path} Đã lưu PDF sẵn sàng in vào {path} + Saved {name} Đã lưu {name} + Saved {place}: {before} → {after} (not smaller){detail} Đã lưu {place}: {before} → {after} (không nhỏ hơn){detail} + Saved {place}: {before} → {after} ({pct}% smaller){detail} Đã lưu {place}: {before} → {after} (nhỏ hơn {pct}%){detail} + Saved, but reopening failed: {e} Đã lưu, nhưng mở lại thất bại: {e} + Screen flicker Nhấp nháy màn hình + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. Trình đọc màn hình chọn giọng đọc dựa trên ngôn ngữ của tài liệu. Hãy đặt trong Thuộc tính tài liệu > Nâng cao > Ngôn ngữ. + Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. Nội dung có script phải dùng được bằng bàn phím và công nghệ hỗ trợ. Hãy kiểm tra các script của tài liệu. + Scripts Script + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Script chạy trong hộp cát không có quyền truy cập tệp hoặc mạng. Khi tắt JavaScript, các hàm định dạng, xác thực và tính toán chuẩn của Acrobat vẫn hoạt động. + Security method Phương thức bảo mật + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Cài đặt bảo mật phải cho phép công nghệ hỗ trợ đọc nội dung ("Cho phép truy cập văn bản cho thiết bị đọc màn hình"). Hãy thay đổi bảo mật của tài liệu để cho phép. + Security will be removed when you save Bảo mật sẽ bị gỡ khi bạn lưu + Self-signed Tự ký + Serial number Số sê-ri + Set alternate text Đặt văn bản thay thế + Set bookmark destination Đặt đích của dấu trang + Set document title Đặt tiêu đề tài liệu + Set status Accepted Đặt trạng thái Đã chấp nhận + Set status Cancelled Đặt trạng thái Đã hủy + Set status Completed Đặt trạng thái Đã hoàn thành + Set status None Đặt trạng thái Không có + Set status Rejected Đặt trạng thái Đã từ chối + Set status Unmarked Đặt trạng thái Chưa đánh dấu + Set tab order Đặt thứ tự tab + Set the image of {name} Đặt hình ảnh của {name} + Shadings Chuyển sắc + Show a field Hiện một trường + Sign with a Digital ID Ký bằng Digital ID + Signed and all signatures are valid. Đã ký và tất cả chữ ký đều hợp lệ. + Signed and saved to {path} Đã ký và lưu vào {path} + Signer Người ký + Signing time Thời gian ký + Social Security Number Số an sinh xã hội + Social Security Numbers Số an sinh xã hội + Solid Liền + Squiggly Lượn sóng + Star Ngôi sao + Strong Mạnh + Structure Info Thông tin cấu trúc + Submit a form Gửi một biểu mẫu + TH and TD TH và TD + TH and TD must be children of TR TH và TD phải là con của TR + TR must be a child of Table, THead, TBody, or TFoot TR phải là con của Table, THead, TBody hoặc TFoot + Tab order Thứ tự tab + Tab order is consistent with structure order Thứ tự tab nhất quán với thứ tự cấu trúc + Table cells belong in a row. Fix the table's tags. Các ô bảng phải nằm trong một hàng. Hãy sửa thẻ của bảng. + Table rows belong directly in a table or its head, body or foot. Fix the table's tags. Các hàng bảng phải nằm trực tiếp trong bảng hoặc phần đầu, thân hay chân của nó. Hãy sửa thẻ của bảng. + Tables Bảng + Tables must contain the same number of columns in each row and rows in each column Bảng phải có cùng số cột trong mỗi hàng và cùng số hàng trong mỗi cột + Tables must have a summary Bảng phải có phần tóm tắt + Tables should have headers Bảng nên có ô tiêu đề + Tagged PDF PDF có gắn thẻ + Tagged annotations Chú thích có gắn thẻ + Tagged content Nội dung có gắn thẻ + Tagged form fields Trường biểu mẫu có gắn thẻ + Tagged multimedia Đa phương tiện có gắn thẻ + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). Thẻ cung cấp cho tài liệu một cấu trúc hợp lý mà công nghệ hỗ trợ dựa theo. Hãy gắn thẻ cho tài liệu (Tự động gắn thẻ PDF). + Text Field Properties Thuộc tính trường văn bản + Text language is specified Đã chỉ định ngôn ngữ văn bản + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. Văn bản phải đủ tương phản với nền để người kém thị lực nhìn được. Hãy kiểm tra màu bằng mắt hoặc bằng công cụ đo độ tương phản. + Text recognition is already running Việc nhận dạng văn bản đang chạy + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Tính năng nhận dạng văn bản chưa được cài đặt: thiếu các mô hình (hãy chạy `cargo xtask models`, hoặc đặt PDFCRAFT_MODELS). + That didn't work: an internal error stopped it ({m}). Không thành công: một lỗi nội bộ đã dừng nó ({m}). + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. Không thành công: một lỗi nội bộ đã dừng nó ({m}). Các tài liệu của bạn không thay đổi. + The Keychain's digital IDs couldn't be listed: {e} Không thể liệt kê các Digital ID của Keychain: {e} + The Windows store's digital IDs couldn't be listed: {e} Không thể liệt kê các Digital ID của kho Windows: {e} + The clipboard has no image or text Bảng tạm không có hình ảnh hoặc văn bản + The document changed while you were choosing a file, so nothing was changed. Tài liệu đã thay đổi trong lúc bạn chọn tệp, nên không có gì bị thay đổi. + The document changed while you were editing field properties. Tài liệu đã thay đổi trong lúc bạn chỉnh sửa thuộc tính trường. + The document has additional actions (/AA) Tài liệu có các hành động bổ sung (/AA) + The document has document-level JavaScript Tài liệu có JavaScript cấp tài liệu + The document has no XMP metadata Tài liệu không có siêu dữ liệu XMP + The document is encrypted Tài liệu đã được mã hóa + The document is no longer open. Tài liệu không còn được mở. + The document was closed before it could be saved. Tài liệu đã bị đóng trước khi kịp lưu. + The document's security settings don't allow filling in form fields Cài đặt bảo mật của tài liệu không cho phép điền các trường biểu mẫu + The field properties could not be changed. Không thể thay đổi thuộc tính của trường. + The field properties dialog is no longer open. Hộp thoại thuộc tính trường không còn được mở. + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. Biểu mẫu yêu cầu được gửi đến {u}; PdfCraft không gửi dữ liệu biểu mẫu. Hãy lưu tài liệu để giữ các mục bạn đã nhập. + The form asks viewers to make appearances (NeedAppearances) Biểu mẫu yêu cầu trình xem tạo diện mạo (NeedAppearances) + The linked PDF opened, but its named destination was not applied. PDF được liên kết đã mở, nhưng đích đến có tên của nó chưa được áp dụng. + The linked PDF opened, but its page destination was out of range. PDF được liên kết đã mở, nhưng đích đến trang của nó nằm ngoài phạm vi. + The operation failed. Thao tác thất bại. + The pages were extracted but not deleted, because the document changed while you were choosing a folder. Các trang đã được trích xuất nhưng chưa bị xóa, vì tài liệu đã thay đổi trong lúc bạn chọn thư mục. + The passwords don't match. Các mật khẩu không khớp. + The selected area has been copied ({w} × {h} pixels) Đã sao chép vùng đã chọn ({w} × {h} điểm ảnh) + The selected fields could not be found. Select them again. Không tìm thấy các trường đã chọn. Hãy chọn lại. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Phông chữ chuẩn không thể vẽ “{c}” ({code}). Hiện chỉ có thể thêm các ký tự Tây Âu làm văn bản. + The title in the metadata and the document information differ Tiêu đề trong siêu dữ liệu và trong thông tin tài liệu khác nhau + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. Cửa sổ nên hiển thị một tiêu đề có ý nghĩa thay vì tên tệp. Hãy đặt tiêu đề và Chế độ xem ban đầu > Hiện: Tiêu đề tài liệu. + There are no redaction marks to apply Không có dấu đánh dấu xóa nào để áp dụng + This PDF uses the unsupported {s} action. PDF này dùng hành động {s} chưa được hỗ trợ. + This compatibility level supports only plain ASCII passwords. Mức tương thích này chỉ hỗ trợ mật khẩu ASCII thuần. + This document can't be checked: it couldn't be read for editing Không thể kiểm tra tài liệu này: không đọc được để chỉnh sửa + This document has no attachments. Tài liệu này không có tệp đính kèm. + This document has no bookmarks. Tài liệu này không có dấu trang. + This document has no form fields. Tài liệu này không có trường biểu mẫu. + This document has no layers. Tài liệu này không có lớp. + This file is already as small as it can be made ({size}). No copy was saved. Tệp này đã nhỏ hết mức có thể ({size}). Không có bản sao nào được lưu. + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. Biểu mẫu này còn chứa dữ liệu XFA mà PdfCraft chưa đọc được. Bạn có thể điền các trường của nó, nhưng Acrobat có thể hiển thị giá trị XFA thay thế. + Thumbnails Hình thu nhỏ + Timed responses Phản hồi có giới hạn thời gian + Type Password Nhập mật khẩu + Type a password. Nhập một mật khẩu. + Typewriter Máy đánh chữ + Unchecked Chưa đánh dấu chọn + Undid {label} Đã hoàn tác {label} + Unlock comment Mở khóa bình luận + Unlocked {n} of {m} files Đã mở khóa {n} trên {m} tệp + Update background Cập nhật nền + Update header & footer Cập nhật đầu trang & chân trang + Update watermark Cập nhật hình mờ + Use {what} Dùng {what} + Valid Hợp lệ + Valid from Có hiệu lực từ + Valid to Có hiệu lực đến + Validate Xác thực + Validity ends Hiệu lực kết thúc + Validity starts Hiệu lực bắt đầu + Vertically Theo chiều dọc + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. Video, âm thanh và đa phương tiện phong phú phải được gắn thẻ để được đọc to. Hãy gắn thẻ cho các chú thích đa phương tiện. + Viewing Xem + Weak Yếu + White on black Trắng trên nền đen + Wrote 1 file to {dir} Đã ghi 1 tệp vào {dir} + Wrote {n} files to {dir} Đã ghi {n} tệp vào {dir} + X Object Forms Đối tượng biểu mẫu X + Yellow Vàng + Yellow on black Vàng trên nền đen + You can pin up to {n} folders. Unpin one first. Bạn có thể ghim tối đa {n} thư mục. Hãy bỏ ghim một thư mục trước. + Your name Tên của bạn + Zip Code Mã bưu chính + Zip Code + 4 Mã bưu chính + 4 + `{command}` {when} `{command}` {when} + a bookmark cannot be moved inside itself không thể di chuyển dấu trang vào bên trong chính nó + a document must keep at least one page tài liệu phải giữ ít nhất một trang + average trung bình + maximum tối đa + minimum tối thiểu + no such document không có tài liệu như vậy + nothing in the file matches this document không có gì trong tệp khớp với tài liệu này + nothing to redo không có gì để làm lại + nothing to undo không có gì để hoàn tác + pages trang + product (x) tích (x) + sum (+) tổng (+) + the document has no form fields tài liệu không có trường biểu mẫu + the document has no page tree tài liệu không có cây trang + the document has no tags tài liệu không có thẻ + the document is protected by a password tài liệu được bảo vệ bằng mật khẩu + the image is empty hình ảnh trống + the password is incorrect mật khẩu không đúng + the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them) các mô hình nhận dạng văn bản chưa được cài đặt (hãy cài đặt lại PdfCraft, hoặc đặt PDFCRAFT_MODELS thành thư mục chứa chúng) + the title must not be empty tiêu đề không được để trống + there are no pages to print không có trang nào để in + there are no redaction marks to apply không có dấu đánh dấu xóa nào để áp dụng + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) tài liệu này đã được ký: việc ghi lại nó sẽ làm mất hiệu lực các chữ ký của nó (hãy lưu tăng dần thay vào đó) + this file isn't XFDF, FDF, XML or text form data tệp này không phải dữ liệu biểu mẫu XFDF, FDF, XML hoặc văn bản + {0}: {1} {0}: {1} + {field}: {e} {field}: {e} + {file} is too large for a stamp (at most {n} MB) {file} quá lớn để làm con dấu (tối đa {n} MB) + {folder} is already pinned to Home {folder} đã được ghim vào Trang chủ + {label} failed: {e} {label} thất bại: {e} + {name} has no action {name} không có hành động + {name} is too large for a word list (at most 1 MB). {name} quá lớn để làm danh sách từ (tối đa 1 MB). + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} gửi biểu mẫu đến {url}; PdfCraft không gửi dữ liệu biểu mẫu. Hãy lưu tài liệu để giữ các mục bạn đã nhập. + {name}: choosing images arrives on the web with file pickers for images {name}: việc chọn hình ảnh sẽ có trên web cùng với bộ chọn tệp hình ảnh + {name}: the {other} action isn't supported yet {name}: hành động {other} chưa được hỗ trợ + {who} in this document tried to open an address PdfCraft won't open: {e}. {who} trong tài liệu này đã cố mở một địa chỉ mà PdfCraft sẽ không mở: {e}. + {who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. {who} trong tài liệu này đã cố mở một địa chỉ mà PdfCraft sẽ không mở: {e}. Chỉ các liên kết web (http, https) và email (mailto) mới được mở từ tài liệu. +animation effects Off Tắt +animation effects On Bật + View more Xem thêm + View less Xem ít hơn + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Chỉ tiếp tục nếu bạn tin cậy tài liệu này. Một địa chỉ có thể mang thông tin từ tài liệu, chẳng hạn những gì bạn đã nhập vào biểu mẫu của nó. + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. In một mặt. Giữ các tờ theo thứ tự, cắt theo các dấu, rồi xếp các chồng từ trái sang phải, từ trên xuống dưới. + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Hãy ký lên một tờ giấy trắng và chụp ảnh dưới ánh sáng sáng, đều (dùng đèn flash). Chọn ảnh hoặc thả vào đây: PdfCraft sẽ tìm chữ ký và loại bỏ phần giấy. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Phông chữ của trường này không thể vẽ “{c}” ({code}), nên PdfCraft hiển thị ? thay thế. Giá trị vẫn được lưu, và các trình xem PDF khác vẽ lại trường từ giá trị đó. + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. Chưa thể ghi {chars} vào PDF: văn bản của Điền & Ký chỉ hỗ trợ ký tự Tây Âu. Hãy chọn định dạng số như dd.mm.yyyy. + January Tháng 1 + February Tháng 2 + March Tháng 3 + April Tháng 4 + May Tháng 5 + June Tháng 6 + July Tháng 7 + August Tháng 8 + September Tháng 9 + October Tháng 10 + November Tháng 11 + December Tháng 12 +calendar date January tháng 1 +calendar date February tháng 2 +calendar date March tháng 3 +calendar date April tháng 4 +calendar date May tháng 5 +calendar date June tháng 6 +calendar date July tháng 7 +calendar date August tháng 8 +calendar date September tháng 9 +calendar date October tháng 10 +calendar date November tháng 11 +calendar date December tháng 12 +calendar weekday Su CN +calendar weekday Mo T2 +calendar weekday Tu T3 +calendar weekday We T4 +calendar weekday Th T5 +calendar weekday Fr T6 +calendar weekday Sa T7 +signature pad Type Nhập chữ +scroll speed Slow Chậm +scroll speed Normal Bình thường +scroll speed Fast Nhanh +certificate Subject Chủ thể chứng chỉ +certificate Issued to Cấp cho +certificate Issued by Cấp bởi +certificate Valid from Có hiệu lực từ +certificate Valid to Có hiệu lực đến +certificate Intended usage Mục đích sử dụng +certificate Version Phiên bản +certificate Serial number Số sê-ri +certificate Issuer Tổ chức cấp +certificate Validity starts Hiệu lực bắt đầu +certificate Validity ends Hiệu lực kết thúc +certificate Public key Khóa công khai +certificate Basic constraints Ràng buộc cơ bản +certificate Key usage Mục đích sử dụng khóa +certificate Self-signed Tự ký +certificate SHA-1 digest Giá trị băm SHA-1 +certificate SHA-256 digest Giá trị băm SHA-256 + Certificate authority Tổ chức chứng thực + End entity Thực thể cuối + Float panel Cho bảng nổi + Move to group Chuyển vào nhóm + Panels Bảng + Resize panel window Đổi kích thước cửa sổ bảng + Resize panels Đổi kích thước các bảng + Grab bag Tổng hợp + No contributor data was built into this copy. Bản sao này không có dữ liệu người đóng góp. + No model credits were built into this copy. Bản sao này không có ghi công mô hình. + Reverse the order Đảo thứ tự + Show Hiện + Sort Sắp xếp + Table Bảng + The field {name} no longer exists. Trường {name} không còn tồn tại. + {n} contributors · {c} commits {n} người đóng góp · {c} commit + Username Tên người dùng + Display name Tên hiển thị + Real name Tên thật + Name (A–Z) Tên (A–Z) + Merged PRs PR đã gộp + PRs PR + Commits Commit + Lines added Dòng thêm + +LOC +LOC + Lines deleted Dòng xóa + −LOC −LOC + Line delta Chênh lệch dòng + ΔLOC ΔLOC + Binary assets added Tài nguyên nhị phân đã thêm + +Bin +Bin + Binary assets removed Tài nguyên nhị phân đã xóa + −Bin −Bin + First commit Commit đầu tiên + First Đầu tiên + Last commit Commit cuối cùng + Last Cuối cùng + Company Công ty + Model Mô hình + Version Phiên bản + % of all commits % tổng số commit + Lines +/− Dòng +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} dòng (Δ {delta}), +{bin_added} / −{bin_deleted} tài nguyên nhị phân, {first} – {last} + of {name} của {name} + of {pages} trên {pages} + on page {label} trên trang {label} + open to see what it will include mở để xem nó sẽ bao gồm những gì + ships in milestone {m} sẽ phát hành ở cột mốc {m} + signature chữ ký + sorted ascending sắp xếp tăng dần + sorted descending sắp xếp giảm dần + to đến + to {n} đến {n} + watermark hình mờ + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy hoặc yy năm · m hoặc mm tháng · mmm hoặc mmmm tên tháng · d hoặc dd ngày · ddd hoặc dddd thứ trong tuần. H, h, M, s và t là các chữ cái chỉ thời gian và không thể dùng riêng lẻ: hãy đặt {escape} trước một chữ cái để hiển thị nguyên văn. + {c} of {n}{more} {c} trên {n}{more} + {dpi} pixels/inch {dpi} điểm ảnh/inch + {kind} Properties Thuộc tính {kind} + {kind} field Trường {kind} + {kind} · page {p} {kind} · trang {p} + {kind} — click to show it on the page {kind} — bấm để hiển thị nó trên trang + {k} of {n} {k} trên {n} + {label} (1 issue) {label} (1 vấn đề) + {label} ({n} issues) {label} ({n} vấn đề) + {month} {day}, {y} {day} {month}, {y} + {month} {y} {month} {y} + {msg} to {dir} {msg} vào {dir} + {name} (default) {name} (mặc định) + {name} can't be combined: {why} Không thể kết hợp {name}: {why} + {name} is read-only {name} chỉ đọc + {name} — unsaved changes {name} — có thay đổi chưa lưu + {n} days ago {n} ngày trước + {n} differences found Tìm thấy {n} điểm khác biệt + {n} fields selected Đã chọn {n} trường + {n} files {n} tệp + {n} files done {n} tệp đã xong + {n} files need attention {n} tệp cần chú ý + {n} h ago {n} giờ trước + {n} instances {n} lần xuất hiện + {n} issues {n} vấn đề + {n} marks {n} dấu + {n} match(es) marked for redaction. Đã đánh dấu {n} kết quả khớp để xóa. + {n} measurements use an unsupported format and are left out. {n} phép đo dùng định dạng không được hỗ trợ và bị bỏ qua. + {n} min ago {n} phút trước + {n} pages {n} trang + {n} pages selected. Đã chọn {n} trang. + {n} selected Đã chọn {n} + {n} tags {n} thẻ + {n} word(s) or phrase(s) {n} từ hoặc cụm từ + {n} {kind} {n} {kind} + {rule} - {status} {rule} - {status} + {s} action Hành động {s} + {verb} Background {verb} nền + {verb} Header and Footer {verb} đầu trang và chân trang + {verb} Watermark {verb} hình mờ + {verb} as "{name}" {verb} thành "{name}" + {who} in this document wants to open this address in your browser: {who} trong tài liệu này muốn mở địa chỉ này trong trình duyệt của bạn: + {who} in this document wants to open your email app with this address: {who} trong tài liệu này muốn mở ứng dụng email của bạn với địa chỉ này: + {words} on {pages} {words} trên {pages} + — (protection is applied when you save) — (việc bảo vệ được áp dụng khi bạn lưu) + “{name}” has security restrictions. Enter its permissions password to lift them. “{name}” có các hạn chế bảo mật. Hãy nhập mật khẩu quyền hạn của nó để gỡ bỏ chúng. + “{name}” is protected. Enter a password to open it. “{name}” được bảo vệ. Hãy nhập mật khẩu để mở. +@plural {n} page|{n} pages {n} trang +@plural {n} file|{n} files {n} tệp +@plural {n} field|{n} fields {n} trường +@plural {n} comment|{n} comments {n} bình luận +@plural {n} result|{n} results {n} kết quả +@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} tên trường trùng với các tệp trước và sẽ được đổi tên: {list} +@plural {n} object selected|{n} objects selected Đã chọn {n} đối tượng +@plural Downloaded {n} PDF|Downloaded {n} PDFs Đã tải xuống {n} PDF +key Ctrl Ctrl +key Shift Shift +key Delete Delete +key Esc Esc +key Home Home +key End End +key Space Space + Move tag earlier Chuyển thẻ lên trước + Move tag later Chuyển thẻ ra sau + Only allow this if you trust the source document. Chỉ cho phép nếu bạn tin tưởng tài liệu nguồn. + Open this local PDF? Mở PDF cục bộ này? + This document is trying to open a local file: Tài liệu này đang cố mở một tệp cục bộ: + Remove "{name}" from this list? The file itself is not deleted. Gỡ "{name}" khỏi danh sách này? Bản thân tệp không bị xóa. + Minimize Thu nhỏ + Maximize Phóng to + Restore Khôi phục + Use system title bar Dùng thanh tiêu đề hệ thống + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Hiện thanh tiêu đề đơn giản cho các nút cửa sổ thay vì dải tab. GNOME không vẽ thanh tiêu đề cho ứng dụng, nên thanh này không theo chủ đề của nó. diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 3ec478255f..258abe89c4 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -313,6 +313,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { ("tr", include_str!("../src/i18n/tr.tsv")), ("sl", include_str!("../src/i18n/sl.tsv")), ("lt", include_str!("../src/i18n/lt.tsv")), + ("vi", include_str!("../src/i18n/vi.tsv")), ] { let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ"); for line in catalog.lines().filter(|l| !l.starts_with('#')) { diff --git a/docs/gaps.md b/docs/gaps.md index 34efa4eff1..9f016bf0de 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1317, #1327, #1302, #1315 and #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** gap 4 closed: the web build prints through the browser's print dialog; exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -38,7 +38,7 @@ update the numbers. | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | | 12 | No measured fidelity harness against Acrobat | quality | (in 2) | [target-app-parity.md](target-app-parity.md) | | 13 | Performance budgets and lazy loading of very large files | performance | 25–45 | this file | -| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese | localization | 30–50 | [localization-parity.md](localization-parity.md) | +| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | | 15 | Forms: JavaScript object model, actions, submit, XFA depth | feature | 40–75 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 16 | UI precision gaps (multi-select move, rulers, accelerators, windows) | UI/UX | 30–60 | [ui-parity.md](ui-parity.md) | | 17 | Certificate security and other protect gaps | feature, spec | 15–30 | [pdf-spec-parity.md](pdf-spec-parity.md) | @@ -272,14 +272,15 @@ update the numbers. to edit. - **Estimate:** 25–45 h. **Doc:** this file. -### 14. Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese +### 14. Arabic not mirrored; no Hindi, Indonesian, Korean - **Missing:** a right-to-left layout (panels, menus, alignment), bidirectional text in the text - layout itself (wrapping, typed text); catalogs for Hindi, Indonesian, Korean and Vietnamese - (Acrobat ships Korean; it ships Arabic in its Middle East edition; it ships none of the other - three). + layout itself (wrapping, typed text); catalogs for Hindi, Indonesian and Korean (Acrobat ships + Korean; it ships Arabic in its Middle East edition; it ships none of Hindi or Indonesian). + Vietnamese ships as a catalog (`vi`, 100% of the current localizable union) but stays `partial` + like other non-English languages (no translated help; engine/OS details stay English). - **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned. -- **Estimate:** 30–50 h (Arabic RTL 20–35, four catalogs 10–15 plus review). **Doc:** +- **Estimate:** 25–45 h (Arabic RTL 20–35, three catalogs 8–12 plus review). **Doc:** [localization-parity.md](localization-parity.md). ### 15. Forms: JavaScript object model, actions, submit, XFA depth diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ccd5f88b90..d4a9a166c4 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (list all registered interface languages; preserve the original coverage snapshot) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Vietnamese 100% of current key union; native-speaker community review; twelve-language mean ≈68%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -8,10 +8,11 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R ## How this was measured - **Strings translated** is measured: the catalogs in `crates/ui-egui/src/i18n/.tsv`, counted - as distinct `(context, English)` keys with a non-empty translation. The denominator is the union - of keys across all catalogs, **2,143**. Cross-check: of the 1,151 distinct `tl!("…")` literals in - `crates/ui-egui/src`, every full catalog covers 96.5–99.4% (7 literals are in no catalog yet). - English is built in. + as distinct `(context, English)` keys with a non-empty translation. The **current** denominator is + the union of keys across all catalogs (**2,590** plain keys as of 2026-10-13, plus language-specific + context rows such as Vietnamese `theme`/`Appearance`). The twelve-language percentage table below + still quotes the **historical 2,143-key snapshot** from 2026-10-10 for every language except + Vietnamese (re-measured against the current union). English is built in. - **Not counted:** error details from the engine and the operating system, which are shown in English inside a translated frame; there is no translated user documentation or help yet (Acrobat ships localized help). @@ -38,15 +39,17 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R | Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | | German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | | Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics; bundled Inter covers them (not tested) | no | none | no | 3–5 | +| Vietnamese | `vi` | 2,591 (100% of current union + theme Appearance) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | -**Localization dimension ≈ 59%**: the mean over the twelve, counting a catalog's percentage but only -60% for Chinese and Arabic until their script problems are fixed, and 0 for the four missing -languages. **≈ 60–115 h** to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans -face, RTL layout, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. +**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only +60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and +Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** +to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans face, RTL layout, bidi text) +and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and -none has translated help. +none has translated help. Vietnamese's **To `full`** hours are therefore residual help/docs polish +(catalog and script work are already done), not missing UI strings. ## Other shipped languages @@ -65,15 +68,16 @@ none has translated help. | Slovenian | `sl` | 2,324 entries | partial | no | | Greek | `el` | 2,341 entries | partial | no | | Turkish | `tr` | 2,313 entries | partial | yes | -| Lithuanian | `lt` | 2,578 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Lithuanian | `lt` | 2,585 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -**14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. -Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. +**14 other languages** ship (22 catalogs plus English = **23** interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12–13; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` | | 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot | | 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names | diff --git a/docs/localization.md b/docs/localization.md index 05240cafbf..89ef2d74d6 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,10 +1,10 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe** or **Lietuvių**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių** or **Tiếng Việt**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, and Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: @@ -12,16 +12,17 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr` or `lt`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese and others) cover the current localizable surface (about 2,000–2,600 entries each); Czech still covers mainly the menus. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,578 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. + +The Vietnamese catalog has 2,591 entries and one plural form (`plural_none`). Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 85d6f5a8b6..e7698baeef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** web printing shipped, gap 4 closed, scores not re-judged; exclusive image-crop resize follow-up; minor (document-font subset completion, source styles and curved/shared image clips in the contributor fork; closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number added, core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) The authoritative assessment of how close PdfCraft is to Acrobat Pro. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one by one; the deep-dive checklists are @@ -21,7 +21,7 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | Feature breadth, unweighted | 56.9% (423 shipped + 74 partial of 808) | measured (`cargo xtask parity` arithmetic) | | Must-have (P0) features | 94.2% (226 + 23 partial of 252) | measured | | Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted: 292 + 44 partial of 497; P0 95.3%) | measured ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | +| **Ready for real work**, full Acrobat Pro | **≈ 50%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (range 50–60%) | estimated ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (range 60–70%) | estimated ([below](#essentials-user)) | | **Stage** | **alpha** (passes the core-workflow gate) | see [Stage](#stage) | @@ -32,7 +32,7 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| -| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | +| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | | Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog | | Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs | @@ -86,8 +86,8 @@ The **full ready-for-real-work number is an additive weighted sum over the dimen method; weights by what decides whether a professional can switch): features 40%, file formats and conversion 12%, UI/UX 10%, PDF specification 8%, stability 8%, performance 6%, localization 5%, platforms 5%, hardware 4%, ecosystem 1%, AI 1%: -0.40 × 49 + 0.12 × 40 + 0.10 × 55 + 0.08 × 60 + 0.08 × 50 + 0.06 × 40 + 0.05 × 59 + 0.05 × 70 + -0.04 × 35 + 0.01 × 30 + 0.01 × 0 = 49.25 → **≈ 49%**. +0.40 × 49 + 0.12 × 40 + 0.10 × 55 + 0.08 × 60 + 0.08 × 50 + 0.06 × 40 + 0.05 × 68 + 0.05 × 70 + +0.04 × 35 + 0.01 × 30 + 0.01 × 0 = 49.7 → **≈ 50%**. History: the first version of this file subtracted 9 points from the area figure for cross-cutting shortfalls (40%); the second look found that a double count (Windows printing, encrypted-output @@ -181,13 +181,13 @@ features total and are shown for orientation; the others add to it. | File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong, encrypted output included (#774 fixed); export Word is layout-poor (#773); no Excel/PowerPoint export, no Office/HTML/PostScript import | [file-format-parity.md](file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 | within features | ISO 32000 syntax, encryption, annotations and AcroForm strong; rendering and fonts borrowed from `hayro`; PDF 2.0 extras, linearization, public-key security absent; PDF/A partial, PDF/X/UA none | [pdf-spec-parity.md](pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; web printing goes through the browser's print dialog as pictures, not vectors | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 59% over the 12 key languages | 5% | 35–60 | additional | 15 catalogs, 13 at 94–100%; Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) | | Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) | | Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 | within features | No plug-in SDK or folder-level JavaScript; Action Wizard partial. Agent automation (CLI, MCP, UI control channel) has no Acrobat equivalent | [gaps.md](gaps.md) | | AI features | ≈ 0% (in scope) | 1% | 20–40 | within features | Acrobat's AI Assistant is cloud-only (out of scope). Local, opt-in provider interface (`misc.ai-provider`) not started | [gaps.md](gaps.md) | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | | | ## By feature area @@ -222,7 +222,7 @@ with evidence when next touched. ## Stage -**alpha.** Ready for real work, ≈ 49%, is inside the 40–75% band, and every one of Acrobat Pro's +**alpha.** Ready for real work, ≈ 50%, is inside the 40–75% band, and every one of Acrobat Pro's six everyday workflows can be completed end to end on macOS. Under the gate rule shared by all the Crafting Apps, a workflow blocks only when a typical user can't complete it at all; lossy exchange or a missing sub-case is a beta item. **Converting and sharing** is therefore partial, not @@ -241,7 +241,7 @@ stage set to pre-alpha; see the revision history.) | Edit text and images | partial, not blocking (Latin text with embedded or standard fonts) | | Convert and share (Office ↔ PDF, password protection) | partial, not blocking (Office→PDF and Word layout #773 are beta items; encryption interop #774 fixed) | -**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 26 points and +**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 25 points and **≈ 400–700 agent-hours**: | Beta requirement | Hours | From 4e06a01631a124abdd86e765d4d53fa34ee5ba1f Mon Sep 17 00:00:00 2001 From: Tristan von Kries Date: Thu, 15 Oct 2026 08:21:21 +0200 Subject: [PATCH 031/207] Edit text: the four-way arrow wherever a drag moves, and a sharp page while editing (#1717) Moving a paragraph or image showed the text cursor over a closed paragraph and a closed hand while dragging, so the drag by its box (and, since #1600, by the frame of the open one) went unnoticed. Now the four-way arrow is offered over a closed paragraph, on the frame band of the open one, over a selected object group and throughout a move; edges keep the rewrap cursor and the open paragraph's text keeps the text cursor. While a paragraph is open, the page around the editor is drawn from one raster capped at the 2048-pixel backdrop side, which went soft past about 1.2x on a Retina display. It is now asked for at the tiling threshold (4096), sharp to about 2.5x on Letter or A4; a GPU with a smaller texture limit still caps it. Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 69 ++++++++++++++++++++++++++---- crates/ui-egui/src/object_ui.rs | 4 +- crates/ui-egui/tests/editing.rs | 44 +++++++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7b66c79a73..f2f60a742e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -28,7 +28,7 @@ const GRID_TAG: u64 = 1 << 61; /// The tag of a raster that is out of date (shown until its replacement arrives). const STALE_TAG: u64 = u64::MAX; /// Pages whose raster would exceed this many device pixels on a side are drawn in tiles. -const TILE_THRESHOLD: f32 = 4096.0; +pub(crate) const TILE_THRESHOLD: f32 = 4096.0; /// Base device-pixel side of a page tile. The actual side grows with the zoom /// ([`tile_side`]), so a zoomed viewport is covered by a few tiles instead of many. const TILE: u32 = 1024; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 9a0870880f..91fbfeb3ae 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -50,8 +50,12 @@ impl TextBackground { } let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; let Some(p) = doc.info.pages.get(page) else { return }; + // One raster stands in for the page's tiles while a paragraph is open, so everything + // around the editor is drawn from it: at the tiling threshold it stays sharp on a Retina + // display up to about 2.5x on a Letter or A4 page, where the backdrop side (2048) went + // soft past 1.2x. let max_side = ctx.input(|i| i.max_texture_side) as f32; - let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); + let scale = scale.min(crate::canvas::TILE_THRESHOLD.min(max_side) / p.width.max(p.height).max(1.0)); let tag = (scale * 1000.0) as u64; if self.tag != Some(tag) { renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); @@ -362,6 +366,8 @@ pub struct BlockDrag { /// How far an edge grip reaches either side of a box's edge (screen pixels). const EDGE_REACH: f32 = 6.0; +/// How far outside an open paragraph's frame the pointer still means the frame (screen pixels). +const FRAME_REACH: f32 = 4.0; /// The rewrap handles drawn on a paragraph box's left and right edges (screen). fn width_handles(b: Rect) -> [Rect; 2] { @@ -565,7 +571,7 @@ pub(crate) fn image_input( ui.ctx().set_cursor_icon(match opposite { Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Grabbing, + None => egui::CursorIcon::Move, }); return true; } @@ -694,7 +700,7 @@ pub(crate) fn page_input( if resizing { draw_handles(preview); } - ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing }); + ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); @@ -742,7 +748,15 @@ pub(crate) fn page_input( if upright && active.is_none() { draw_handles(boxes[hit]); } - ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text }); + // A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it + // for typing); an edge rewraps it. The open one takes the text cursor. + ui.ctx().set_cursor_icon(if on_edge { + egui::CursorIcon::ResizeHorizontal + } else if active.is_none() { + egui::CursorIcon::Move + } else { + egui::CursorIcon::Text + }); if resp.clicked() { view.image_selection = None; let l = &lines[hit]; @@ -1021,6 +1035,14 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // can differ from the PDF's. Keep every visible row inside the growing frame. let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + // On the frame, a press takes hold of the box (a click selects it, a drag moves it): + // the pointer says so there, and reads as text inside. + if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && live.expand(2.0 + FRAME_REACH).contains(p) + && !live.expand(2.0).contains(p) + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } // White, unless the text is too pale to read on white (white text from a coloured // banner, #913): then a dark box, with a caret that shows on it. let fill = if background_ready { Color32::TRANSPARENT } else { editor_fill(text_color) }; @@ -1097,15 +1119,13 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; - #[test] - fn text_background_is_cached_without_editing_and_released_on_cancel() { + /// An app with one text document open, and an editor on its first paragraph. + fn preview_app() -> (crate::PdfCraftApp, LineEditor) { let mut app = crate::PdfCraftApp::new(); let bytes = app.session.create_from_text("Preview", "Preview text").unwrap(); app.open_bytes("preview.pdf", None, bytes.as_ref().clone()).unwrap(); let doc = app.session.get(app.views[0].id).unwrap(); let block = doc.text_blocks(0).remove(0); - let source = doc.bytes.clone(); - let generation = doc.edit_generation(); let ed = LineEditor { selection: None, font_resource: None, @@ -1127,6 +1147,15 @@ mod tests { extras: Extras::default(), extras0: Extras::default(), }; + (app, ed) + } + + #[test] + fn text_background_is_cached_without_editing_and_released_on_cancel() { + let (app, ed) = preview_app(); + let doc = app.session.get(app.views[0].id).unwrap(); + let source = doc.bytes.clone(); + let generation = doc.edit_generation(); let ctx = egui::Context::default(); let mut background = TextBackground::default(); for _ in 0..100 { @@ -1148,6 +1177,30 @@ mod tests { assert!(background.texture.is_none() && background.renderer.is_none()); } + #[test] + fn the_editing_background_stays_sharp_on_a_zoomed_page() { + // While a paragraph is open, the page around it is drawn from this one raster: it is + // asked for at the tiling threshold, not the 2048-pixel backdrop side, which went soft + // beyond about 1.2x on a Retina display. + let (app, ed) = preview_app(); + let doc = app.session.get(app.views[0].id).unwrap(); + let page = &doc.info.pages[0]; + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }); + let mut background = TextBackground::default(); + background.prepare(&ctx, doc, Some(&ed), 8.0); + let expected = 8.0f32.min(crate::canvas::TILE_THRESHOLD / page.width.max(page.height)); + assert!(expected > crate::canvas::BASE_SIDE / page.width.max(page.height), "the test page needs the larger side"); + assert_eq!(background.tag, Some((expected * 1000.0) as u64)); + // A GPU that can't hold such a texture caps it. + ctx.end_pass().textures_delta.clear(); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(2048), ..Default::default() }); + let mut small = TextBackground::default(); + small.prepare(&ctx, doc, Some(&ed), 8.0); + assert_eq!(small.tag, Some(((2048.0 / page.width.max(page.height)) * 1000.0) as u64)); + ctx.end_pass().textures_delta.clear(); + } + #[test] fn pale_text_is_edited_on_a_dark_box() { // #913: white text from a coloured banner was typed onto a white box and vanished. diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 118946bd0c..025e72d5ed 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -232,7 +232,7 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag } else { s.gesture = Some(gesture); } - ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Grabbing }); + ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move }); return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { @@ -242,7 +242,7 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag if s.page == Some(page) && !s.selected.is_empty() { let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); if union.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } return true; } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 7c2f96f34d..3d365761d6 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4270,3 +4270,47 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() { assert_eq!(std::fs::read(path).unwrap(), original); println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages"); } + +/// Moving text shows the four-way arrow wherever a drag would move it (#1520 follow-up): over a +/// closed paragraph, on the frame of an open one, and while dragging. Inside the open one the +/// pointer reads as text; on a closed one's edge, as the rewrap handle. +#[test] +fn moving_text_offers_the_four_way_arrow() { + let mut h = harness(2, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k); + let centre = screen((block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0); + let cursor_at = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(2); + h.output().platform_output.cursor_icon + }; + // A closed paragraph: a drag moves it; its left edge rewraps it. + assert_eq!(cursor_at(&mut h, centre), egui::CursorIcon::Move); + assert_eq!(cursor_at(&mut h, screen(block.rect[0], (block.rect[1] + block.rect[3]) / 2.0)), egui::CursorIcon::ResizeHorizontal); + // While it is being dragged. + h.hover_at(centre); + h.run_steps(1); + h.drag_at(centre); + h.run_steps(1); + let mid = centre + egui::vec2(20.0 * k, 10.0 * k); + h.hover_at(mid); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move); + h.drop_at(mid); + h.run_steps(4); + let moved = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + assert!((moved.rect[0] - block.rect[0] - 20.0).abs() < 1.5, "{:?} → {:?}", block.rect, moved.rect); + // Open: text inside, the frame moves it. + let centre = screen((moved.rect[0] + moved.rect[2]) / 2.0, (moved.rect[1] + moved.rect[3]) / 2.0); + click_at(&mut h, centre); + assert!(h.state().views[0].line_editor.is_some()); + assert_eq!(cursor_at(&mut h, centre), egui::CursorIcon::Text); + let frame = screen((moved.rect[0] + moved.rect[2]) / 2.0, moved.rect[3]) - egui::vec2(0.0, 4.0); + assert_eq!(cursor_at(&mut h, frame), egui::CursorIcon::Move); +} From 659c8fcbd43c60ff45e6e8698158ec9bf01f0ab9 Mon Sep 17 00:00:00 2001 From: Tristan von Kries Date: Thu, 15 Oct 2026 08:21:32 +0200 Subject: [PATCH 032/207] macOS: the standard About panel shows the website and Discord links (#1287) (#1718) PdfCraft > About PdfCraft is AppKit's standard panel (winit's default menu), which takes Credits.rtf from Contents/Resources. Ship one with the lines the in-app Help > About has: the one-sentence description, the licence, and links to getartcraft.com and the Discord. Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 --- packaging/macos/Credits.rtf | 10 ++++++++++ packaging/macos/package.sh | 3 +++ 2 files changed, 13 insertions(+) create mode 100644 packaging/macos/Credits.rtf diff --git a/packaging/macos/Credits.rtf b/packaging/macos/Credits.rtf new file mode 100644 index 0000000000..18c053f5d9 --- /dev/null +++ b/packaging/macos/Credits.rtf @@ -0,0 +1,10 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2822 +{\fonttbl\f0\fswiss\fcharset0 Helvetica;} +{\colortbl;\red255\green255\blue255;} +\pard\qc\f0\fs24\cf0 +A clean-room, open-source PDF application written in Rust.\ +MIT OR Apache-2.0.\ +\ +{\field{\*\fldinst{HYPERLINK "https://getartcraft.com"}}{\fldrslt getartcraft.com}}\ +Help and feedback: {\field{\*\fldinst{HYPERLINK "https://discord.gg/artcraft"}}{\fldrslt discord.gg/artcraft}}\ +} diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index e04be13c8d..267a87280b 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,9 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/pdfcraft" "$APP/Contents/MacOS/PdfCraft" cp "$ROOT/assets/app-icon/pdfcraft.icns" "$APP/Contents/Resources/PdfCraft.icns" +# The standard About panel (PdfCraft menu > About PdfCraft) shows Credits.rtf under the version: +# the website and Discord links the in-app About dialog has (#1287). +cp "$HERE/Credits.rtf" "$APP/Contents/Resources/Credits.rtf" copy_font_licences "$APP/Contents/Resources" # OCR models: the app finds them at Contents/MacOS/../Resources/models. Before signing, so the # bundle's seal covers them. From 5b220eb8868bb344da865fc6f59de7ada9331aa5 Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:21:42 +0200 Subject: [PATCH 033/207] M6: detect form fields on scanned pages (#1721) (#1722) Field detection read only vector data: text-layer words and drawn rectangles and lines. A scanned form is a single picture, so it found nothing there. A page that an image covers at least half of, and whose content draws no lines, is now rendered at 144 dpi and its shapes are read from the pixels (pdfcraft_forms::detect::raster): ink against a local mean, long thin runs as lines, enclosed rectangular patches of paper as boxes, the empty band of a box with a small printed label as its writing area, and the open-ended horizontal lines as rules. Labels come from the text layer, else from OCR when the models are installed; without either the fields get default names. Detection works in the displayed frame, so a scan stored sideways reads like any other, and fields on a turned page get /MK /R so typing reads upright. generated with Claude Code --- crates/automation/src/tools.rs | 2 +- crates/engine/src/js.rs | 60 +++- crates/engine/src/lib.rs | 1 + crates/engine/src/scan_fields.rs | 144 ++++++++ crates/engine/src/tests.rs | 149 ++++++++ crates/forms/src/detect.rs | 4 +- crates/forms/src/detect/raster.rs | 550 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 893 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/scan_fields.rs create mode 100644 crates/forms/src/detect/raster.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1ba549cf0d..98bf2754af 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1026,7 +1026,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d636c303fc..8958688d2d 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -250,6 +250,15 @@ fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::Pag /// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are /// words of their own. pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<(String, [f64; 4])> { + words_with(text, |x, y| info.view_to_user(x, y)) + .into_iter() + .map(|(text, r)| (text, [f64::from(r[0]), f64::from(r[1]), f64::from(r[2]), f64::from(r[3])])) + .collect() +} + +/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered +/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). +pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { let mut words: Vec<(String, [f32; 4])> = Vec::new(); let mut last_line = u32::MAX; // A space glyph ends the word before it. @@ -281,18 +290,23 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende words .into_iter() .map(|(text, r)| { - let a = info.view_to_user(r[0], r[1]); - let b = info.view_to_user(r[2], r[3]); - let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64]; - (text, rect) + let a = map(r[0], r[1]); + let b = map(r[2], r[3]); + (text, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]) }) .collect() } impl Session { /// Prepare a form ▸ automatic field detection: the fields `pages` (0-based; empty = all) - /// seem to ask for, named from their labels, as (page, candidate). + /// seem to ask for, named from their labels, as (page, candidate). Scanned pages (a picture + /// and no drawn shapes) are read from their pixels, see [`crate::scan_fields`]. pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> { + self.detect_fields_turned(id, pages).into_iter().map(|(page, c, _)| (page, c)).collect() + } + + /// [`Self::detect_fields`], with how far each field turns (`/MK /R`) to read upright. + fn detect_fields_turned(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate, i64)> { let Some(doc) = self.get(id) else { return Vec::new() }; let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; @@ -302,13 +316,19 @@ impl Session { let list: Vec = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() }; let mut out = Vec::new(); for page in list { + let Some(info) = doc.info.pages.get(page) else { continue }; let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let words = res.text.map(|t| detection_words(&t, &doc.info.pages[page])).unwrap_or_default(); let shapes = pdfcraft_forms::detect::page_shapes(cos, page); let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect(); - for c in pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken) { + let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) { + self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken) + } else { + let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default(); + (pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0) + }; + for c in found { taken.push(c.name.clone()); - out.push((page, c)); + out.push((page, c, turn)); } } out @@ -316,23 +336,29 @@ impl Session { /// Detect fields and add them, as one undoable step. Returns the new fields' names. pub fn auto_detect_fields(&mut self, id: DocId, pages: &[usize]) -> Result, EditError> { - let found = self.detect_fields(id, pages); + let found = self.detect_fields_turned(id, pages); if found.is_empty() { return Ok(Vec::new()); } - let names = found.iter().map(|(_, c)| c.name.clone()).collect(); - let edits = found - .into_iter() - .map(|(page, c)| Edit::AddField { + let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); + let mut edits = Vec::with_capacity(found.len()); + for (page, c, turn) in found { + // A turned field keeps its place: the rectangle is already the one on the page. + let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); + let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; + edits.push(Edit::AddField { page, rect: c.rect, kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 }, + pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, }, - name: Some(c.name), - }) - .collect(); + name: Some(c.name.clone()), + }); + if let Some(props) = props { + edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); + } + } self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?; Ok(names) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f170a07d10..b3b9a1e032 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ pub mod ocr; pub mod optimizer; pub mod printcheck; mod scan_colours; +mod scan_fields; pub mod signature_image; pub mod signature_scan; pub mod text_fonts; diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs new file mode 100644 index 0000000000..8287743fc5 --- /dev/null +++ b/crates/engine/src/scan_fields.rs @@ -0,0 +1,144 @@ +//! Prepare a form ▸ field detection on scanned pages. A page that is a picture has no text and +//! no drawn shapes to read, so it is rendered and its lines and boxes are read from the pixels +//! ([`pdfcraft_forms::detect::raster`]). The labels come from the page's text layer (an earlier +//! Recognize text), else from text recognition when its models are installed. Without either, +//! the fields are still found and get the default names (Text1, Check Box1, …). +//! +//! Detection works in the page as displayed (after `/Rotate`), in points with y up, so a scan +//! stored sideways and turned upright by `/Rotate` reads like any other page. The fields are +//! mapped back to user space and turned with the page (`/MK /R`), so typed text reads upright. + +use pdfcraft_forms::detect::{Candidate, Word, detect, raster}; +use pdfcraft_render::{PageInfo, PageRenderer, PageText, RenderRequest}; + +use crate::{DocId, Document, Session, ocr}; + +/// Pixels per point the page is rendered at for its lines (144 dpi). +const SCALE: f32 = 2.0; +/// The longest side of that rendering, in pixels. +const MAX_SIDE: f32 = 3000.0; +/// A page is a scan when its images cover at least this share of it. +const COVERED: f64 = 0.5; +/// Recognised "words" less tall than this (points) are specks read as letters, and would make +/// the box they sit on look filled in. +const MIN_WORD_HEIGHT: f64 = 4.0; + +/// Whether `page` is a picture of a page: images cover most of it. +pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let page_area = (cx1 - cx0) * (cy1 - cy0); + if !page_area.is_finite() || page_area <= 0.0 { + return false; + } + let covered: f64 = doc + .page_images(page) + .iter() + .filter(|i| !i.is_form && i.width > 1 && i.height > 1) + .map(|i| { + let r = i.visible_rect.unwrap_or(i.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + w * h + }) + .filter(|a| a.is_finite()) + .sum(); + covered >= COVERED * page_area +} + +/// The page as displayed, in points with y up: where detection on a scan works. +struct Frame<'a>(&'a PageInfo); + +impl Frame<'_> { + /// The box around view-space points (y down). + fn view_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + let h = f64::from(self.0.height); + let mut out = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for [x, y] in points { + let (x, y) = (f64::from(x), h - f64::from(y)); + out = [out[0].min(x), out[1].min(y), out[2].max(x), out[3].max(y)]; + } + out.iter().all(|v| v.is_finite()).then_some(out) + } + + /// A user-space rectangle (or the box around user-space points) in the frame. + fn user_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + self.view_box(points.into_iter().map(|[x, y]| self.0.user_to_view(x as f32, y as f32))) + } + + /// A frame rectangle in user space. + fn user_rect(&self, r: [f64; 4]) -> [f64; 4] { + let h = f64::from(self.0.height); + let a = self.0.view_to_user(r[0] as f32, (h - r[1]) as f32); + let b = self.0.view_to_user(r[2] as f32, (h - r[3]) as f32); + [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))] + } +} + +fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { + [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] +} + +impl Session { + /// The fields on scanned `page` of `id`, in user space, and how far the page turns them + /// (its `/Rotate`). `text` is the page's text layer, `existing` its widget rectangles. + pub(crate) fn detect_on_scan( + &self, + id: DocId, + r: &mut PageRenderer, + page: usize, + text: Option<&PageText>, + existing: &[[f64; 4]], + taken: &[String], + ) -> (Vec, i64) { + let Some(info) = self.get(id).and_then(|d| d.info.pages.get(page)) else { return (Vec::new(), 0) }; + let frame = Frame(info); + let scale = SCALE.min(MAX_SIDE / info.width.max(info.height).max(1.0)); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + if shot.error.is_some() || shot.width == 0 { + return (Vec::new(), 0); + } + let px_per_pt = f64::from(shot.width) / f64::from(info.width.max(1e-3)); + let shapes = raster::shapes(&shot.rgba, shot.width, shot.height, px_per_pt); + if shapes.boxes.is_empty() && shapes.rules.is_empty() { + return (Vec::new(), 0); + } + let mut words: Vec = text + .map(|t| crate::js::words_with(t, |x, y| [x, y])) + .unwrap_or_default() + .into_iter() + .filter_map(|(text, v)| Some(Word { text, rect: frame.view_box([[v[0], v[1]], [v[2], v[3]]])? })) + .collect(); + if words.is_empty() { + words = self.recognized_words(id, page, &frame); + } + let existing: Vec<[f64; 4]> = existing.iter().filter_map(|r| frame.user_box(corners(*r))).collect(); + let found = detect(&words, &shapes, &existing, taken); + let turn = i64::from(info.rotation % 360); + (found.into_iter().map(|c| Candidate { rect: frame.user_rect(c.rect), ..c }).collect(), turn) + } + + /// The words text recognition reads on `page`, in the frame; none without the models. + fn recognized_words(&self, id: DocId, page: usize, frame: &Frame) -> Vec { + if !ocr::available() { + return Vec::new(); + } + let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else { + return Vec::new(); + }; + job.run(&engine, |_, _| true) + .into_iter() + .flat_map(|p| p.words) + .filter_map(|w| { + let o = w.origin; + let points = [ + o, + [o[0] + w.across[0], o[1] + w.across[1]], + [o[0] + w.up[0], o[1] + w.up[1]], + [o[0] + w.across[0] + w.up[0], o[1] + w.across[1] + w.up[1]], + ]; + Some(Word { rect: frame.user_box(points)?, text: w.text }) + }) + .filter(|w| w.rect[3] - w.rect[1] >= MIN_WORD_HEIGHT) + .collect() + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 133587202d..2711d19f77 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2990,6 +2990,155 @@ fn detecting_fields_on_a_printed_form() { assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect"); } +/// A printed form, 420 × 300 pt: a title, a label with a rule to write on, a box with a small +/// label in its corner, an empty box with its label above, and two check boxes. +fn paper_form() -> Vec { + let content = "BT /F1 14 Tf 30 270 Td (Gewerbe-Ummeldung) Tj ET \ + BT /F1 10 Tf 30 230 Td (Name:) Tj ET 0.75 w 80 228 m 380 228 l S \ + 1 w 30 150 350 50 re S BT /F1 7 Tf 34 191 Td (Anschrift) Tj ET \ + BT /F1 8 Tf 30 124 Td (Telefon) Tj ET 30 90 350 30 re S \ + 30 50 10 10 re S BT /F1 9 Tf 46 51 Td (Haupt) Tj ET 150 50 10 10 re S BT /F1 9 Tf 166 51 Td (Zweig) Tj ET"; + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(obj); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// How the paper form was scanned. +#[derive(Clone, Copy, Debug)] +enum Scan { + Straight, + /// Stored sideways and turned upright by `/Rotate 90`. + Sideways, + /// Speckled paper, uneven light and lines that lose some pixels. + Noisy, + /// Laid on the scanner a degree askew. + Skewed, +} + +/// The paper form as an image-only PDF at 150 dpi (the page keeps its 420 × 300 pt). +fn scanned_form(scan: Scan) -> Arc> { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let mut img = image::load_from_memory(&png).unwrap().to_rgba8(); + match scan { + Scan::Straight => {} + Scan::Sideways => img = image::imageops::rotate270(&img), + Scan::Skewed => { + // Turned a degree about the centre (nearest pixel; the corners stay paper). + let (w, h) = (img.width() as f64, img.height() as f64); + let (sin, cos) = 1f64.to_radians().sin_cos(); + let src = img.clone(); + for (x, y, p) in img.enumerate_pixels_mut() { + let (dx, dy) = (x as f64 - w / 2.0, y as f64 - h / 2.0); + let (sx, sy) = (cos * dx + sin * dy + w / 2.0, -sin * dx + cos * dy + h / 2.0); + *p = if sx >= 0.0 && sy >= 0.0 && sx < w && sy < h { *src.get_pixel(sx as u32, sy as u32) } else { image::Rgba([255; 4]) }; + } + } + Scan::Noisy => { + let (w, mut seed) = (img.width(), 7u32); + for (x, _, p) in img.enumerate_pixels_mut() { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let noise = ((seed >> 16) % 48) as u8; + let shade = (x * 50 / w) as u8; + for c in &mut p.0[..3] { + *c = if *c < 128 && noise < 4 { 230 } else { c.saturating_sub(shade).saturating_sub(noise) }; + } + } + } + } + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let pdf = s.create_from_images_with_resolution(&[("scan.png".into(), bytes.into_inner())], ImageResolution::Dpi(150.0)).unwrap(); + if !matches!(scan, Scan::Sideways) { + return pdf; + } + let id = s.open("scan.pdf", None, pdf, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.save_bytes(id).unwrap() +} + +/// Fields on a scanned paper form are found from its lines and boxes, wherever the page turns +/// them, and named from the labels when text recognition is installed. +#[test] +fn detecting_fields_on_a_scanned_form() { + use pdfcraft_forms::detect::Kind::{CheckBox, Text}; + for scan in [Scan::Straight, Scan::Sideways, Scan::Noisy, Scan::Skewed] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, scanned_form(scan), None).unwrap(); + assert_eq!(page_texts(&s, id), [""], "{scan:?}: a picture has no text"); + let info = s.get(id).unwrap().info.pages[0].clone(); + assert_eq!((info.width.round(), info.height.round()), (420.0, 300.0), "{scan:?}"); + // Where a field shows on the page, in points from the bottom-left as displayed. + let shown = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), 300.0 - a[1].max(b[1]), a[0].max(b[0]), 300.0 - a[1].min(b[1])].map(f64::from) + }; + let found = s.detect_fields(id, &[]); + let got: Vec<(pdfcraft_forms::detect::Kind, [f64; 4])> = found.iter().map(|(_, c)| (c.kind.clone(), shown(c.rect))).collect(); + // In reading order: the rule after "Name:", the box below "Anschrift", the "Telefon" + // box, two check boxes. + let want = [ + (Text, [80.0, 229.0, 380.0, 245.0]), + (Text, [31.0, 151.0, 379.0, 189.0]), + (Text, [31.0, 91.0, 379.0, 119.0]), + (CheckBox, [30.0, 50.0, 40.0, 60.0]), + (CheckBox, [150.0, 50.0, 160.0, 60.0]), + ]; + assert_eq!(got.len(), want.len(), "{scan:?}: {got:?}"); + for ((kind, r), (want_kind, w)) in got.iter().zip(&want) { + assert_eq!(kind, want_kind, "{scan:?}: {got:?}"); + // The labelled box's top is wherever the label ends; everything else is close. A + // degree askew, a 350 pt line climbs 6 pt, and the field stays inside the box. + let tol = if matches!(scan, Scan::Skewed) { [6.5; 4] } else { [2.5, 2.5, 2.5, if w[3] == 189.0 { 6.0 } else { 2.5 }] }; + assert!(r.iter().zip(w).zip(tol).all(|((a, b), t)| (a - b).abs() <= t), "{scan:?}: {r:?}, want {w:?}"); + } + let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect(); + if ocr::available() { + // (Askew, recognition may read "Haupt" and "Zweig" as one phrase.) + assert!(names.contains(&"Name") && names.contains(&"Telefon") && names.iter().any(|n| n.starts_with("Haupt")), "{scan:?}: {names:?}"); + } else { + // Without text recognition the fields get the default names. + assert!(names.iter().all(|n| n.starts_with("Text") || n.starts_with("Check Box")), "{scan:?}: {names:?}"); + } + + // Added as one step, where they were found; on the turned page each field turns with + // it, so typing reads upright. + let added = s.auto_detect_fields(id, &[]).unwrap(); + assert_eq!(added.len(), want.len()); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Detect form fields")); + let turn = if matches!(scan, Scan::Sideways) { 90 } else { 0 }; + for (_, c) in &found { + let f = d.form.iter().find(|f| f.name == c.name).unwrap(); + assert_eq!(f.widgets[0].rotation, turn, "{scan:?}: {}", f.name); + let r = f.widgets[0].rect; + assert!(r.iter().zip(c.rect).all(|(a, b)| (a - b).abs() < 0.01), "{scan:?}: {r:?} moved from {:?}", c.rect); + } + assert!(s.detect_fields(id, &[]).is_empty(), "{scan:?}: nothing left to detect"); + } +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 685a818a9c..dbee788e3b 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -3,11 +3,13 @@ //! field from the label next to it (Acrobat's auto-naming). //! //! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads -//! the shapes from a page's content. +//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). use pdfcraft_content::{Matrix, parse}; use pdfcraft_cos::{Document, Object}; +pub mod raster; + /// A piece of text on the page, in user space. #[derive(Clone, Debug, PartialEq)] pub struct Word { diff --git a/crates/forms/src/detect/raster.rs b/crates/forms/src/detect/raster.rs new file mode 100644 index 0000000000..d54e36e5c1 --- /dev/null +++ b/crates/forms/src/detect/raster.rs @@ -0,0 +1,550 @@ +//! Field detection on scanned pages: the [`Shapes`] that [`detect`](super::detect) needs, read +//! from a picture of the page instead of its content stream. +//! +//! Steps: +//! - Ink is a pixel clearly darker than the paper around it (a local mean, so the uneven paper +//! of a scan or photo still reads as paper). +//! - Straight lines are runs of ink at least [`MIN_LINE`] long, horizontal or vertical, with +//! short gaps (noise, a faint print) bridged. +//! - A box is a patch of paper that the lines enclose and that is close to a rectangle. Its +//! writing area is the whole box when it is empty, or else the largest band of empty rows or +//! columns in it (many official forms print a small label in the corner of each box). +//! - The remaining horizontal lines, the ones that are not the edge of a box, are rules. +//! +//! Everything is bounded: pictures larger than [`MAX_PIXELS`] are refused (the caller renders +//! at a resolution that fits), and the results are capped. + +use super::Shapes; + +/// The largest picture analysed (about A3 at 200 dpi). +pub const MAX_PIXELS: usize = 12_000_000; +/// The shortest run of ink that counts as a line, in points (a check box side is at least 5). +const MIN_LINE: f64 = 4.0; +/// Gaps up to this long (points) inside a line are bridged. +const GAP: f64 = 1.0; +/// Lines thicker than this (points, on average) are dark areas, not rules. +const MAX_THICKNESS: f64 = 3.0; +/// The shortest rule reported, in points ([`detect`](super::detect) wants 40 for a field). +const MIN_RULE: f64 = 20.0; +/// A box is at least this share of its bounding rectangle (a skewed scan loses a little). +const RECTANGULAR: f64 = 0.75; +/// Boxes taller than this (points) are frames around parts of the form, not places to write. +const MAX_BOX_HEIGHT: f64 = 120.0; +/// At most this many boxes and rules are reported. +const MAX_SHAPES: usize = 4000; + +/// A grey picture (0 black, 255 white) and its size. +struct Grey { + w: usize, + h: usize, + v: Vec, +} + +/// The shapes in `rgba` (premultiplied RGBA8, `width` × `height`, the whole page as displayed), +/// where `px_per_pt` pixels make a point. Coordinates are points with the origin at the +/// picture's bottom-left and y up, the frame [`detect`](super::detect) expects. Empty when the +/// picture is malformed or larger than [`MAX_PIXELS`]. +pub fn shapes(rgba: &[u8], width: u32, height: u32, px_per_pt: f64) -> Shapes { + let (w, h) = (width as usize, height as usize); + let valid = w >= 8 + && h >= 8 + && px_per_pt.is_finite() + && (0.25..=20.0).contains(&px_per_pt) + && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS) + && w.checked_mul(h).and_then(|n| n.checked_mul(4)) == Some(rgba.len()); + if !valid { + return Shapes::default(); + } + let grey = grey(rgba, w, h); + let ink = ink(&grey, px_per_pt); + let px = |pt: f64| (pt * px_per_pt).round().max(1.0) as usize; + let (min_line, gap) = (px(MIN_LINE), px(GAP)); + let horizontal = runs(&ink, w, h, min_line, gap, true); + let vertical = runs(&ink, w, h, min_line, gap, false); + // The lines, a pixel thicker so that a hairline crack in a corner still encloses. + let mut lines: Vec = horizontal.iter().zip(&vertical).map(|(a, b)| *a || *b).collect(); + lines = dilate(&lines, w, h); + let areas = enclosed(&lines, &ink, w, h, px_per_pt); + let to_pt = |x: usize, y: usize| [x as f64 / px_per_pt, (h as f64 - y as f64) / px_per_pt]; + let mut out = Shapes::default(); + for [x0, y0, x1, y1] in areas.into_iter().take(MAX_SHAPES) { + let (a, b) = (to_pt(x0, y1), to_pt(x1, y0)); + out.boxes.push([a[0], a[1], b[0], b[1]]); + } + let tol = px(2.0); + // Whether a vertical line passes the end of a horizontal one at column `x`. + let attached = |x: usize, l: &Component| { + (l.y0.saturating_sub(tol)..l.y1.saturating_add(tol).min(h)) + .any(|y| (x.saturating_sub(tol)..x.saturating_add(tol + 1).min(w)).any(|x| vertical.get(y * w + x).copied().unwrap_or(false))) + }; + for l in components(&horizontal, w, h) { + let len = l.x1 - l.x0; + let thick = l.count as f64 / len.max(1) as f64; + if (len as f64) < MIN_RULE * px_per_pt || thick > MAX_THICKNESS * px_per_pt { + continue; + } + let y = l.y_sum as f64 / l.count.max(1) as f64; + // The scan's own border is not a line on the form. + if y < tol as f64 || y > h.saturating_sub(tol) as f64 { + continue; + } + // Closed at both ends: the edge of a box, a table or a frame, not a line to write on. + if attached(l.x0, &l) && attached(l.x1 - 1, &l) { + continue; + } + let y = (h as f64 - y) / px_per_pt; + if out.rules.len() < MAX_SHAPES { + out.rules.push([l.x0 as f64 / px_per_pt, y, l.x1 as f64 / px_per_pt, y]); + } + } + out +} + +/// Luminance over white paper (a transparent pixel is paper). +fn grey(rgba: &[u8], w: usize, h: usize) -> Grey { + let v = rgba + .as_chunks::<4>() + .0 + .iter() + .map(|&[r, g, b, a]| { + let (r, g, b, a) = (u32::from(r), u32::from(g), u32::from(b), u32::from(a)); + // Premultiplied over white: c + (255 - a). + let l = (r * 77 + g * 150 + b * 29) >> 8; + (l + (255 - a)).min(255) as u8 + }) + .collect(); + Grey { w, h, v } +} + +/// Which pixels are ink: darker than 80 % of the mean around them (a window of about 20 points), +/// or dark outright. +fn ink(g: &Grey, px_per_pt: f64) -> Vec { + let (w, h) = (g.w, g.h); + let r = ((10.0 * px_per_pt).round() as usize).clamp(2, 200); + // A separable box mean: rows first, then columns. Sums stay below 255 × 401 × 401 < 2^32. + let mut rows = vec![0u32; w * h]; + for y in 0..h { + let line = g.v.get(y * w..(y + 1) * w).unwrap_or_default(); + let mut sum: u32 = line.iter().take(r + 1).map(|v| u32::from(*v)).sum(); + let mut n = (r + 1).min(w) as u32; + for x in 0..w { + if let Some(slot) = rows.get_mut(y * w + x) { + *slot = sum / n.max(1); + } + if let Some(v) = line.get(x + r + 1) { + sum += u32::from(*v); + n += 1; + } + if x >= r + && let Some(v) = line.get(x - r) + { + sum = sum.saturating_sub(u32::from(*v)); + n = n.saturating_sub(1); + } + } + } + let mut out = vec![false; w * h]; + for x in 0..w { + let at = |y: usize| rows.get(y * w + x).copied().unwrap_or(255); + let mut sum: u32 = (0..=r.min(h - 1)).map(at).sum(); + let mut n = (r + 1).min(h) as u32; + for y in 0..h { + let mean = sum / n.max(1); + let i = y * w + x; + let v = u32::from(g.v.get(i).copied().unwrap_or(255)); + if let Some(slot) = out.get_mut(i) { + *slot = v < 96 || (v * 5 < mean * 4 && v < 224); + } + if y + r + 1 < h { + sum += at(y + r + 1); + n += 1; + } + if y >= r { + sum = sum.saturating_sub(at(y - r)); + n = n.saturating_sub(1); + } + } + } + out +} + +/// The pixels on straight runs of ink at least `min` long, along rows (`across`) or columns, +/// with gaps up to `gap` bridged. A run is nearly solid: a word in small print, its letters +/// run together by noise, has more gaps than a line. +fn runs(ink: &[bool], w: usize, h: usize, min: usize, gap: usize, across: bool) -> Vec { + let mut out = vec![false; w * h]; + let (outer, inner) = if across { (h, w) } else { (w, h) }; + let index = |o: usize, i: usize| if across { o * w + i } else { i * w + o }; + for o in 0..outer { + // The current run: its first and last inked position and how many are inked. + let mut run: Option<(usize, usize, usize)> = None; + let close = |run: Option<(usize, usize, usize)>, out: &mut Vec| { + if let Some((a, b, n)) = run + && b + 1 - a >= min + && n * 4 >= (b + 1 - a) * 3 + { + for i in a..=b { + if let Some(slot) = out.get_mut(index(o, i)) { + *slot = true; + } + } + } + }; + for i in 0..inner { + if !ink.get(index(o, i)).copied().unwrap_or(false) { + continue; + } + run = match run { + Some((a, b, n)) if i - b <= gap + 1 => Some((a, i, n + 1)), + other => { + close(other, &mut out); + Some((i, i, 1)) + } + }; + } + close(run, &mut out); + } + out +} + +/// `mask` grown by one pixel in each of the four directions. +fn dilate(mask: &[bool], w: usize, h: usize) -> Vec { + let at = |x: usize, y: usize| mask.get(y * w + x).copied().unwrap_or(false); + let mut out = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let on = at(x, y) || (x > 0 && at(x - 1, y)) || (x + 1 < w && at(x + 1, y)) || (y > 0 && at(x, y - 1)) || (y + 1 < h && at(x, y + 1)); + if let Some(slot) = out.get_mut(y * w + x) { + *slot = on; + } + } + } + out +} + +/// Where to write in the rectangular patches of paper that `lines` enclose (none that touch +/// the picture's edge), in pixels: [x0, y0, x1, y1) with y down. +fn enclosed(lines: &[bool], ink: &[bool], w: usize, h: usize, px_per_pt: f64) -> Vec<[usize; 4]> { + let mut label = vec![0u32; w * h]; + let mut out = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut next: u32 = 0; + let max_h = (MAX_BOX_HEIGHT * px_per_pt) as usize; + for start in 0..w * h { + if lines.get(start).copied().unwrap_or(true) || label.get(start).copied().unwrap_or(1) != 0 { + continue; + } + next = next.saturating_add(1); + if next == u32::MAX { + break; + } + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0); + let mut count = 0usize; + let mut border = false; + stack.clear(); + stack.push(start); + if let Some(l) = label.get_mut(start) { + *l = next; + } + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + count += 1; + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + border |= x == 0 || y == 0 || x + 1 == w || y + 1 == h; + let mut visit = |j: usize| { + if !lines.get(j).copied().unwrap_or(true) + && let Some(l) = label.get_mut(j) + && *l == 0 + { + *l = next; + stack.push(j); + } + }; + if x > 0 { + visit(i - 1); + } + if x + 1 < w { + visit(i + 1); + } + if y > 0 { + visit(i - w); + } + if y + 1 < h { + visit(i + w); + } + } + let (bw, bh) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + let min = (MIN_LINE * px_per_pt) as usize; + if border || bw < min || bh < min || bh > max_h || (count as f64) < RECTANGULAR * (bw * bh) as f64 { + continue; + } + if let Some(area) = writing_area(&label, ink, [w, h], [x0, y0, x1, y1], next, px_per_pt) { + out.push(area); + } + if out.len() >= MAX_SHAPES { + break; + } + } + out +} + +/// Where to write in the region `id` with bounding rectangle `rect`, in pixels: all of it when it is empty, +/// else the largest band of empty rows big enough for a line of handwriting (below or above a +/// label), else the largest such band of columns (beside one). A small square (a check box) +/// with ink in it is already ticked or is a letter, not a blank. Ink counts anywhere in the +/// rectangle except inside other regions: the strokes of a label are lines of their own. +/// +/// A side of the area that is a side of the region reaches out to the middle of the line around +/// it (the region stops short of it by the dilation and half a line); a side that faces a label +/// keeps a point away from it. +fn writing_area(label: &[u32], ink: &[bool], [w, h]: [usize; 2], rect: [usize; 4], id: u32, px_per_pt: f64) -> Option<[usize; 4]> { + let [x0, y0, x1, y1] = rect; + let px = |pt: f64| (pt * px_per_pt).round().max(1.0) as usize; + let (pad, gap) = (1 + px(0.5), px(1.0)); + // `area` within `rect`: its sides on the region's sides padded, the others moved in. + let place = |a: [usize; 4]| { + let lo = |v: usize, edge: usize| if v == edge { v.saturating_sub(pad) } else { v + gap }; + let hi = |v: usize, edge: usize, max: usize| if v == edge { v.saturating_add(pad).min(max) } else { v.saturating_sub(gap) }; + let out = [lo(a[0], x0), lo(a[1], y0), hi(a[2], x1, w), hi(a[3], y1, h)]; + (out[0] < out[2] && out[1] < out[3]).then_some(out) + }; + let (bw, bh) = (x1 - x0, y1 - y0); + let mut rows = vec![0usize; bh]; + let mut cols = vec![0usize; bw]; + for y in y0..y1 { + for x in x0..x1 { + let i = y * w + x; + let mine = matches!(label.get(i), Some(&l) if l == id || l == 0); + if mine && ink.get(i).copied().unwrap_or(false) { + if let Some(r) = rows.get_mut(y - y0) { + *r += 1; + } + if let Some(c) = cols.get_mut(x - x0) { + *c += 1; + } + } + } + } + // A few specks per row are noise, not print. + let (row_noise, col_noise) = ((bw / 200).max(1), (bh / 200).max(1)); + if rows.iter().all(|n| *n <= row_noise) { + return place(rect); + } + if bw as f64 <= 24.0 * px_per_pt && bh as f64 <= 24.0 * px_per_pt { + return None; + } + let longest = |counts: &[usize], allowed: usize| { + let (mut best, mut start) = ((0, 0), None); + for (i, c) in counts.iter().chain(std::iter::once(&usize::MAX)).enumerate() { + match (start, *c <= allowed) { + (None, true) => start = Some(i), + (Some(s), false) => { + if i - s > best.1 - best.0 { + best = (s, i); + } + start = None; + } + _ => {} + } + } + best + }; + let (r0, r1) = longest(&rows, row_noise); + let (c0, c1) = longest(&cols, col_noise); + let tall = |n: usize| n as f64 >= 12.0 * px_per_pt; + let wide = |n: usize| n as f64 >= 40.0 * px_per_pt; + let band = (tall(r1 - r0) && wide(bw)).then_some([x0, y0 + r0, x1, y0 + r1]); + let column = (wide(c1 - c0) && tall(bh)).then_some([x0 + c0, y0, x0 + c1, y1]); + band.or(column).and_then(place) +} + +/// A connected piece of a line mask. +struct Component { + x0: usize, + x1: usize, + y0: usize, + y1: usize, + count: usize, + /// The sum of the rows of its pixels (u64: on 32-bit targets it outgrows `usize`). + y_sum: u64, +} + +/// The 8-connected pieces of `mask`. +fn components(mask: &[bool], w: usize, h: usize) -> Vec { + let mut seen = vec![false; w * h]; + let mut out = Vec::new(); + let mut stack: Vec = Vec::new(); + for start in 0..w * h { + if !mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut c = Component { x0: usize::MAX, x1: 0, y0: usize::MAX, y1: 0, count: 0, y_sum: 0 }; + stack.clear(); + stack.push(start); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + c.x0 = c.x0.min(x); + c.x1 = c.x1.max(x + 1); + c.y0 = c.y0.min(y); + c.y1 = c.y1.max(y + 1); + c.count += 1; + c.y_sum = c.y_sum.saturating_add(y as u64); + for dy in [-1isize, 0, 1] { + for dx in [-1isize, 0, 1] { + let (Some(nx), Some(ny)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { continue }; + if nx >= w || ny >= h { + continue; + } + let j = ny * w + nx; + if mask.get(j).copied().unwrap_or(false) + && let Some(s) = seen.get_mut(j) + && !*s + { + *s = true; + stack.push(j); + } + } + } + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A white page `w` × `h` pixels to draw a form on, two pixels to the point. + struct Page { + w: usize, + h: usize, + px: Vec, + } + + const S: f64 = 2.0; + + impl Page { + fn new(w: usize, h: usize) -> Self { + Page { w, h, px: vec![255; w * h * 4] } + } + fn fill(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, v: u8) { + for y in y0..y1.min(self.h) { + for x in x0..x1.min(self.w) { + let i = (y * self.w + x) * 4; + self.px[i..i + 3].copy_from_slice(&[v, v, v]); + } + } + } + /// An outlined box, `t` pixels thick. + fn frame(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, t: usize) { + self.fill(x0, y0, x1, y0 + t, 0); + self.fill(x0, y1 - t, x1, y1, 0); + self.fill(x0, y0, x0 + t, y1, 0); + self.fill(x1 - t, y0, x1, y1, 0); + } + /// Something like a word: short strokes, none a line. + fn word(&mut self, x: usize, y: usize, letters: usize) { + for i in 0..letters { + self.fill(x + i * 12, y, x + i * 12 + 2, y + 14, 30); + self.fill(x + i * 12 + 4, y + 6, x + i * 12 + 9, y + 8, 30); + } + } + fn shapes(&self) -> Shapes { + shapes(&self.px, self.w as u32, self.h as u32, S) + } + } + + /// Points (y up) of a pixel rectangle on a page `h` pixels high. + fn pt(h: usize, [x0, y0, x1, y1]: [usize; 4]) -> [f64; 4] { + [x0 as f64 / S, (h - y1) as f64 / S, x1 as f64 / S, (h - y0) as f64 / S] + } + + fn near(a: [f64; 4], b: [f64; 4], tol: f64) -> bool { + a.iter().zip(b).all(|(p, q)| (p - q).abs() <= tol) + } + + #[test] + fn a_scanned_form_gives_its_rules_boxes_and_check_boxes() { + let mut p = Page::new(600, 400); + // A label and the rule to write on after it. + p.word(20, 40, 4); + p.fill(80, 56, 400, 58, 0); + // An empty box, and a box with a small label in its top-left corner. + p.frame(20, 100, 420, 150, 2); + p.frame(20, 180, 420, 260, 2); + p.word(26, 186, 3); + // A check box (10 pt) with a label to its right. + p.frame(20, 300, 40, 320, 2); + p.word(50, 303, 5); + let s = p.shapes(); + assert_eq!(s.rules.len(), 1, "{s:?}"); + let r = s.rules[0]; + assert!((r[0] - 40.0).abs() < 1.5 && (r[2] - 200.0).abs() < 1.5 && (r[1] - (400.0 - 57.0) / S).abs() < 1.0, "{r:?}"); + assert_eq!(s.boxes.len(), 3, "{s:?}"); + // The empty box is the inside of its frame (give or take the line). + assert!(s.boxes.iter().any(|b| near(*b, pt(400, [20, 100, 420, 150]), 2.0)), "{s:?}"); + // The labelled box: only the part below the label. + let lower = s.boxes.iter().find(|b| (b[1] - (400.0 - 260.0) / S).abs() < 2.0).expect("labelled box"); + assert!(lower[3] <= (400.0 - 200.0) / S + 2.0 && lower[3] - lower[1] >= 12.0, "{lower:?}"); + // The check box is square and about 10 pt. + assert!(s.boxes.iter().any(|b| near(*b, pt(400, [20, 300, 40, 320]), 1.5)), "{s:?}"); + } + + #[test] + fn text_and_a_frame_around_the_page_are_not_shapes() { + let mut p = Page::new(400, 300); + for line in 0..6 { + p.word(30, 30 + line * 30, 20); + } + // A frame around the whole page, and a solid dark bar (a logo, a censored part). + p.frame(4, 4, 396, 296, 2); + p.fill(40, 240, 360, 270, 0); + let s = p.shapes(); + assert!(s.boxes.is_empty() && s.rules.is_empty(), "{s:?}"); + } + + #[test] + fn noise_and_uneven_paper_do_not_hide_the_lines() { + let mut p = Page::new(600, 300); + p.fill(80, 56, 400, 58, 0); + p.frame(20, 100, 420, 150, 2); + p.frame(20, 200, 40, 220, 2); + // Paper that darkens to the right (a shadow) and speckled with deterministic noise; the + // lines lose some pixels. + let mut seed: u32 = 12345; + for y in 0..p.h { + for x in 0..p.w { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let noise = (seed >> 16) % 41; + let i = (y * p.w + x) * 4; + let v = p.px[i]; + let shade = (x * 60 / p.w) as u8; + let v = if v == 0 { if noise < 3 { 200 } else { 40 + noise as u8 } } else { 255u8.saturating_sub(shade).saturating_sub(noise as u8) }; + p.px[i..i + 3].copy_from_slice(&[v, v, v]); + } + } + let s = p.shapes(); + assert_eq!(s.rules.len(), 1, "{s:?}"); + assert_eq!(s.boxes.len(), 2, "{s:?}"); + } + + #[test] + fn malformed_pictures_give_nothing() { + let white = vec![255u8; 64 * 64 * 4]; + assert_eq!(shapes(&white, 64, 64, 2.0), Shapes::default()); + // Size and data disagree, sizes overflow, or the scale is nonsense. + assert_eq!(shapes(&white, 64, 65, 2.0), Shapes::default()); + assert_eq!(shapes(&white, u32::MAX, u32::MAX, 2.0), Shapes::default()); + assert_eq!(shapes(&[], 0, 0, 2.0), Shapes::default()); + for s in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e9] { + assert_eq!(shapes(&white, 64, 64, s), Shapes::default(), "{s}"); + } + // All black: one dark area, no lines. + let black: Vec = [0u8, 0, 0, 255].repeat(64 * 64); + assert_eq!(shapes(&black, 64, 64, 2.0), Shapes::default()); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bf39b83f8c..a7c5f8300d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5064,8 +5064,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Not yet: comb boxes, tables of cells, scanned forms (needs OCR first), radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" From 2048a18e53e39f4c9f70095622d94daeda1e1c7f Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 08:21:53 +0200 Subject: [PATCH 034/207] M2: Bound literal text search with prefix matching (#1725) Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> --- crates/render/examples/search_bench.rs | 29 +++++++ crates/render/src/text.rs | 44 +--------- crates/render/src/text/search.rs | 77 +++++++++++++++++ crates/render/tests/search.rs | 112 +++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 crates/render/examples/search_bench.rs create mode 100644 crates/render/src/text/search.rs create mode 100644 crates/render/tests/search.rs diff --git a/crates/render/examples/search_bench.rs b/crates/render/examples/search_bench.rs new file mode 100644 index 0000000000..f65d959c5e --- /dev/null +++ b/crates/render/examples/search_bench.rs @@ -0,0 +1,29 @@ +//! Synthetic search microbenchmark, including normalization and glyph-range mapping. +//! Run with `cargo run -p pdfcraft-render --example search_bench --locked` on both revisions +//! with the same toolchain/profile. Timings are diagnostic, never a CI deadline. +use pdfcraft_render::{PageText, TextGlyph}; +use std::{hint::black_box, time::Instant}; + +fn main() { + for (name, text, query) in [ + ("repeated prefix", "a".repeat(131072), format!("{}b", "a".repeat(4095))), + ("ordinary", "The quick brown fox jumps over the lazy dog. ".repeat(3000), "brown fox".into()), + ("unicode", "İSTANBUL κόσμος Straße العربية 日本語 ".repeat(3000), "istanbul".into()), + ] { + let glyphs: Vec<_> = + text.chars().map(|c| TextGlyph { text: c.to_string(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(); + let n = glyphs.len(); + let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }; + black_box(page.find(&query)); + let mut times = Vec::new(); + for _ in 0..7 { + let start = Instant::now(); + black_box(page.find(black_box(&query))); + times.push(start.elapsed().as_secs_f64() * 1000.0); + } + times.sort_by(f64::total_cmp); + if let Some(median) = times.get(3) { + println!("{name}: median {median:.3} ms (7 runs, {n} glyphs)"); + } + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c513d5249b..92b6be98e4 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -16,6 +16,8 @@ use hayro::hayro_interpret::{ use kurbo::{Affine, BezPath, Rect, Shape}; use std::collections::HashMap; +mod search; + /// One glyph on the page. #[derive(Clone, Debug, PartialEq)] pub struct TextGlyph { @@ -102,47 +104,7 @@ impl PageText { /// Search with Acrobat's find options, returning at most `limit` glyph ranges. pub fn find_opts_limited(&self, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec> { - if limit == 0 { - return Vec::new(); - } - let fold = |s: &str| if case_sensitive { s.to_string() } else { fold_case(s) }; - let needle: Vec = fold(needle).split_whitespace().collect::>().join(" ").chars().collect(); - if needle.is_empty() { - return Vec::new(); - } - // Flatten to chars with the glyph index each char came from. - let mut chars: Vec<(char, usize)> = Vec::new(); - for (i, g) in self.glyphs.iter().enumerate() { - if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| c.0 != ' ') { - chars.push((' ', i)); - } - for c in fold(&g.text).chars() { - let c = if c.is_whitespace() { ' ' } else { c }; - // The query collapses a run of whitespace to one space, so collapse the page too. - if c == ' ' && chars.last().is_some_and(|k| k.0 == ' ') { - continue; - } - chars.push((c, i)); - } - } - let word = |k: Option<&(char, usize)>| k.is_some_and(|c| c.0.is_alphanumeric()); - let mut out = Vec::new(); - let mut i = 0; - while i + needle.len() <= chars.len() { - let bounded = !whole_words || (!word(i.checked_sub(1).and_then(|p| chars.get(p))) && !word(chars.get(i + needle.len()))); - if bounded && chars[i..i + needle.len()].iter().map(|c| c.0).eq(needle.iter().copied()) { - let start = chars[i].1; - let end = chars[i + needle.len() - 1].1 + 1; - out.push(start..end); - if out.len() == limit { - break; - } - i += needle.len(); - } else { - i += 1; - } - } - out + search::find(self, needle, case_sensitive, whole_words, limit) } /// Glyph ranges of the matches a character matcher finds in the page text (words and lines diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs new file mode 100644 index 0000000000..c68660dbe9 --- /dev/null +++ b/crates/render/src/text/search.rs @@ -0,0 +1,77 @@ +//! Literal search in normalized page text, retaining the original glyph owners. +use super::{PageText, fold_case}; +use std::ops::Range; + +pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec> { + if limit == 0 { + return Vec::new(); + } + let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; + let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); + if needle.is_empty() { + return Vec::new(); + } + let mut chars: Vec<(char, usize)> = Vec::new(); + for (i, glyph) in page.glyphs.iter().enumerate() { + let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b); + let gap = page.space_before.get(i).copied().unwrap_or(false) || line_break; + if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') { + chars.push((' ', i)); + } + for c in fold(&glyph.text).chars() { + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { + chars.push((c, i)); + } + } + } + if needle.len() > chars.len() { + return Vec::new(); + } + // KMP reuses a matched prefix instead of comparing a long query again at every position. + // The table is bounded by the page's already materialized character count. + let mut prefix = vec![0; needle.len()]; + let mut matched = 0; + for (i, &c) in needle.iter().enumerate().skip(1) { + matched = advance(&needle, &prefix, matched, c); + if let Some(slot) = prefix.get_mut(i) { + *slot = matched; + } + } + matched = 0; + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric()); + let mut hits = Vec::new(); + for (i, &(c, owner)) in chars.iter().enumerate() { + matched = advance(&needle, &prefix, matched, c); + if matched != needle.len() { + continue; + } + let end = i.saturating_add(1); + let start = end.saturating_sub(needle.len()); + let bounded = !whole_words || (!word(start.checked_sub(1)) && !word(Some(end))); + if bounded { + if let Some((_, first)) = chars.get(start) + && let Some(last) = owner.checked_add(1) + { + hits.push(*first..last); + if hits.len() == limit { + break; + } + } + // Only accepted hits consume their full span: literal search is non-overlapping. + matched = 0; + } else { + // A word-boundary rejection must still allow an overlapping later candidate. + matched = prefix.last().copied().unwrap_or_default(); + } + } + hits +} + +/// Extend a prefix, falling back through strictly shorter proper prefixes on a mismatch. +fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> usize { + while matched > 0 && needle.get(matched) != Some(&c) { + matched = prefix.get(matched - 1).copied().unwrap_or_default(); + } + if needle.get(matched) == Some(&c) { matched.saturating_add(1) } else { 0 } +} diff --git a/crates/render/tests/search.rs b/crates/render/tests/search.rs new file mode 100644 index 0000000000..44fbe49350 --- /dev/null +++ b/crates/render/tests/search.rs @@ -0,0 +1,112 @@ +use pdfcraft_render::{PageText, TextGlyph, text::fold_case}; +use std::ops::Range; + +fn page(parts: &[&str]) -> PageText { + PageText { + glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(), + line_of: vec![0; parts.len()], + space_before: vec![false; parts.len()], + } +} + +// The original straightforward search is an independent oracle for valid page metadata. +fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { + let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; + let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); + if needle.is_empty() || limit == 0 { + return Vec::new(); + } + let mut chars = Vec::new(); + for (i, glyph) in page.glyphs.iter().enumerate() { + if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') { + chars.push((' ', i)); + } + for c in fold(&glyph.text).chars() { + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') { + chars.push((c, i)); + } + } + } + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric()); + let mut hits = Vec::new(); + let mut start = 0; + while start + needle.len() <= chars.len() { + let end = start + needle.len(); + if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) { + hits.push(chars[start].1..chars[end - 1].1 + 1); + if hits.len() == limit { + break; + } + start = end; + } else { + start += 1; + } + } + hits +} + +#[test] +fn search_tolerates_missing_layout_metadata() { + let mut text = page(&["a", "b", "c"]); + text.line_of.clear(); + text.space_before.clear(); + assert_eq!(text.find("abc"), vec![0..3]); + text.space_before = vec![false, true]; + assert_eq!(text.find("a bc"), vec![0..3]); + text.line_of = vec![0, 1]; + assert_eq!(text.find("a bc"), vec![0..3]); +} + +#[test] +fn whole_word_rejection_keeps_overlapping_candidates() { + let text = page(&["xaa-aa-aa"]); + assert_eq!(text.find_opts("aa-aa", true, true), vec![0..1]); + // Accepted matches still do not overlap, even within one multi-character glyph. + assert_eq!(page(&["aaaaa"]).find("aaa"), vec![0..1]); + assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); +} + +#[test] +fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"]; + let mut seed = 1234567u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 32) as usize + }; + for case in 0..2000 { + let parts: Vec<_> = (0..10 + case % 70).map(|_| alphabet[next() % alphabet.len()]).collect(); + let query = if case % 2 == 0 { + parts.iter().skip(case % 8).take(1 + case % 9).copied().collect::() + } else { + (0..1 + case % 9).map(|_| alphabet[next() % alphabet.len()]).collect::() + }; + let mut text = page(&parts); + for i in 1..parts.len() { + text.space_before[i] = next() % 11 == 0; + text.line_of[i] = text.line_of[i - 1] + u32::from(next() % 17 == 0); + } + for sensitive in [false, true] { + for whole in [false, true] { + for limit in [0, 1, 2, usize::MAX] { + assert_eq!( + text.find_opts_limited(&query, sensitive, whole, limit), + reference(&text, &query, sensitive, whole, limit), + "case {case}: {query:?}, sensitive={sensitive}, whole={whole}, limit={limit}" + ); + } + } + } + } +} + +#[test] +fn long_repeated_prefix_search_preserves_the_last_match_and_limit() { + let mut parts = vec!["a"; 131072]; + parts.push("b"); + let text = page(&parts); + let query = format!("{}b", "a".repeat(4095)); + assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]); + assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty()); +} From b5039ca6039f39aadc8b8ddbd0888caf21f10fe8 Mon Sep 17 00:00:00 2001 From: umesh429 Date: Thu, 15 Oct 2026 07:22:03 +0100 Subject: [PATCH 035/207] fix(ui): clicking a theme card in Preferences picks that theme (#1727) Preferences > Appearance drew a Light and a Dark theme card, each with a radio row, but neither the card nor the radio responded to a click: the radio's response was discarded and the frame never sensed clicks. Only the Appearance mode combo box changed the theme. The whole card and its radio rows now run view.theme.light / view.theme.dark, the same commands as the combo box, so the combo box shows the choice. A card click is a manual choice that OS theme changes leave alone, and Use system setting afterwards follows the OS again. Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/appearance.rs | 49 ++++++++++++++++++--- crates/ui-egui/tests/ui_group/theme.rs | 61 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs index e279399270..0d607cc910 100644 --- a/crates/ui-egui/src/appearance.rs +++ b/crates/ui-egui/src/appearance.rs @@ -83,9 +83,9 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } /// One theme card: its title, Active when it is what shows now, a preview of the chosen theme -/// and a radio row per theme. -fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) { - egui::Frame::new() +/// and a radio row per theme. Returns true when the card or one of its radio rows was clicked. +fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { + let inner = egui::Frame::new() .fill(t.card) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border })) .corner_radius(6.0) @@ -102,11 +102,16 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); preview(ui, shown, ui.available_width()); ui.add_space(4.0); + let mut clicked = false; for (kind, label) in themes { // One theme per card today, so it is always the chosen one. - let _ = ui.add(widgets::radio(*kind == shown, tl!(label))); + clicked |= ui.add(widgets::radio(*kind == shown, tl!(label))).clicked(); } + clicked }); + // The whole card picks its theme, not just the radio row. + let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + inner.inner || whole.clicked() } /// The Appearance section of Preferences. @@ -127,8 +132,12 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let light_active = app.theme == ThemeKind::Light; ui.columns(2, |cols| { let [light, dark] = cols else { return }; - card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES); - card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES); + if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES) { + app.execute(mode_command(ThemePreference::Light)); + } + if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES) { + app.execute(mode_command(ThemePreference::Dark)); + } }); } @@ -179,4 +188,32 @@ mod tests { h.run(); assert_eq!(h.state().theme_preference, ThemePreference::System); } + + /// Clicking a theme card, or its radio row, picks that theme, not only the mode combo box. + #[test] + fn clicking_a_theme_card_picks_that_theme() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let mut fonts = false; + let mut h = Harness::builder().with_size(egui::vec2(640.0, 400.0)).build_ui_state( + move |ui, app: &mut PdfCraftApp| { + if !fonts { + ui.ctx().set_fonts(theme::font_definitions()); + fonts = true; + return; + } + let t = Tokens::get(ui.ctx()); + preferences_section(ui, app, &t); + }, + app, + ); + h.run(); + h.get_by_label("Dark gray").click(); + h.run(); + assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Dark, ThemeKind::Dark)); + // The card's title is not a widget of its own: the click lands on the card. + h.get_by_label("Light theme").click(); + h.run(); + assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Light, ThemeKind::Light)); + } } diff --git a/crates/ui-egui/tests/ui_group/theme.rs b/crates/ui-egui/tests/ui_group/theme.rs index 2a7e0e2653..6e86c5099f 100644 --- a/crates/ui-egui/tests/ui_group/theme.rs +++ b/crates/ui-egui/tests/ui_group/theme.rs @@ -204,3 +204,64 @@ fn native_appearance_remains_inherited_for_every_saved_choice() { assert_eq!(h.ctx.options(|o| o.theme_preference), egui::ThemePreference::System); } } + +/// Preferences ▸ Appearance: a theme card is a manual choice that the mode combo box shows and +/// OS changes leave alone; Use system setting afterwards follows the OS again. +#[test] +fn preferences_theme_cards_and_use_system_setting_stay_in_sync() { + use egui::accesskit::Role; + fn mode_shown(h: &Harness<'static, PdfCraftApp>) -> String { + let combo = + h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| ["Use system setting", "Light", "Dark"].contains(&v.as_str()))); + combo.value().unwrap_or_default() + } + let mut h = harness(); + h.get_by_label("Display theme: Light gray").click_secondary(); + h.run_steps(4); + h.get_by_label("Appearance settings…").click(); + h.run_steps(10); + assert_theme(&h, ThemePreference::Light, ThemeKind::Light); + assert_eq!(mode_shown(&h), "Light"); + + // The OS is dark: the Dark card's radio row picks Dark, and the combo box says so. + // The cards sit below the fold of the Preferences scroll area at this window size. + h.get_by_label("Dark gray").scroll_to_me(); + h.run_steps(3); + h.get_by_label("Dark gray").click(); + h.run_steps(3); + assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark); + assert_eq!(mode_shown(&h), "Dark"); + // The Light card itself, not its radio row, picks Light, and a dark OS doesn't undo it. + h.get_by_label("Light theme").click(); + h.run_steps(3); + h.input_mut().system_theme = Some(egui::Theme::Light); + h.run_steps(2); + h.input_mut().system_theme = Some(egui::Theme::Dark); + h.run_steps(2); + assert_theme(&h, ThemePreference::Light, ThemeKind::Light); + assert_eq!(mode_shown(&h), "Light"); + + // Use system setting follows the OS both ways after a card choice. + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Light")).scroll_to_me(); + h.run_steps(3); + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Light")).click(); + h.run_steps(3); + // Motion has a "Use system setting" radio of its own; this is the combo box's item. + h.get_by_role_and_label(Role::Button, "Use system setting").click(); + h.run_steps(3); + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + assert_eq!(mode_shown(&h), "Use system setting"); + h.input_mut().system_theme = Some(egui::Theme::Light); + h.run_steps(3); + assert_theme(&h, ThemePreference::System, ThemeKind::Light); + // Only the Light card is marked Active while the OS is light. + assert_eq!(h.query_all_by_label("Active").count(), 1); + + // A card click leaves system mode again. + h.get_by_label("Dark theme").scroll_to_me(); + h.run_steps(3); + h.get_by_label("Dark theme").click(); + h.run_steps(3); + assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark); + assert_eq!(mode_shown(&h), "Dark"); +} From 8e74a4850c5fcdff72dc1b34b5052ae336728340 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:22:15 +0800 Subject: [PATCH 036/207] Issue #1676: put fallback faces on the interface baseline (#1743) Chinese next to Latin in one label sat a few percent of the font size above or below the Latin, depending on the installed Chinese face. egui centres a fallback face's line box on the primary face's instead of lining up their baselines, so the fallback's baseline moves by half the difference of ascent + descent (less half the difference of the line gaps): +0.047 em for Microsoft JhengHei, +0.034 em for Microsoft YaHei, -0.075 em for SimSun under Inter. Fallback faces (the installed system face and the craft-fonts faces) now get a FontTweak::y_offset_factor that cancels that shift, computed from their own metrics with the skrifa egui uses, so any face lands on Inter's baseline. skrifa moves from the native-only dependencies to all targets (egui already brings it to wasm). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/Cargo.toml | 5 ++- crates/ui-egui/src/system_fonts.rs | 2 +- crates/ui-egui/src/theme.rs | 69 +++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index e45c596881..e02441cf6b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -31,6 +31,9 @@ png = "0.18" base64 = "0.22" # Right-to-left file names in the chrome (egui shapes but does not reorder); already in the tree. unicode-bidi = "0.3" +# Checks that an installed fallback font parses and has the glyphs, and reads the vertical metrics +# that put fallback faces on the interface's baseline (#1676); the same skrifa egui uses. +skrifa = "0.44" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Control-channel tokens. @@ -39,8 +42,6 @@ getrandom = "0.4" arboard = { version = "3.6", default-features = false, features = ["image-data"] } # Waits for native file pickers off the UI thread (already in the tree through wgpu). pollster = "1.0" -# Checks that an installed fallback font parses and has the glyphs; the same skrifa egui uses. -skrifa = "0.44" [target.'cfg(target_os = "macos")'.dependencies] # Where the pointer is while files are dragged over the window (winit doesn't say): safe AppKit diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 4712c3149f..e64533ee12 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -304,7 +304,7 @@ fn read(path: &Path, probe: char, max: u64) -> Option> { let mut data = FontData::from_owned(bytes); data.index = index; log::info!("interface font fallback: {} (face {index})", path.display()); - Some(Arc::new(data)) + Some(Arc::new(crate::theme::on_interface_baseline(data))) } /// The first face of the file that parses and maps `c`. egui parses fonts with the same skrifa, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9a0620b167..d28a7b6580 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -315,6 +315,40 @@ fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak { tweak } +/// A face's vertical metrics in em, as egui reads them: skrifa's ascent, descent (negative) and +/// line gap at the default location. `None` when the face doesn't parse. +fn vertical_metrics(bytes: &[u8], index: u32) -> Option<[f32; 3]> { + use skrifa::MetadataProvider as _; + let font = skrifa::FontRef::from_index(bytes, index).ok()?; + let m = font.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let em = f32::from(m.units_per_em); + let metrics = [m.ascent / em, m.descent / em, m.leading / em]; + (em > 0.0 && metrics.iter().all(|v| v.is_finite())).then_some(metrics) +} + +/// How far below the primary face's baseline egui draws a fallback face's, in em of the font size +/// (negative: above). egui centres the fallback's line box (ascent − descent + gap) on the +/// primary's rather than lining up their baselines, so the baseline moves by half the difference +/// of ascent + descent, less half the difference of the line gaps (epaint `text_layout`). +fn baseline_drop(primary: [f32; 3], fallback: [f32; 3]) -> f32 { + let [pa, pd, pg] = primary; + let [a, d, g] = fallback; + ((a + d) - (pa + pd) + pg - g) / 2.0 +} + +/// `data` with its glyphs moved onto Inter's baseline (#1676). Without this, Chinese next to Latin +/// in one label sits higher or lower than the Latin by a few percent of the font size, depending on +/// the installed Chinese face (PingFang, Microsoft JhengHei, SimSun…). Inter is the first face of +/// every proportional family; the monospace family's JetBrains Mono differs from it by 0.004 em, +/// well under a pixel. A face that doesn't parse is left as it is (egui will skip it anyway). +pub(crate) fn on_interface_baseline(mut data: FontData) -> FontData { + let primary = vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0); + if let (Some(primary), Some(fallback)) = (primary, vertical_metrics(&data.font, data.index)) { + data.tweak.y_offset_factor = -baseline_drop(primary, fallback); + } + data +} + fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { @@ -322,7 +356,7 @@ fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform let data = if ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"].contains(&name) { data.tweak(primary_font_tweak(platform, name)) } else { - data + on_interface_baseline(data) }; fonts.font_data.insert(name.to_owned(), Arc::new(data)); }; @@ -556,6 +590,39 @@ mod tests { ) } + /// The baseline of each glyph of `text` laid out by egui at `size` points, in points from the + /// top of the row: its position plus its face's `y_offset_factor`, as epaint draws it. + fn baselines(fallback: FontData, size: f32, text: &str) -> Vec { + let factor = fallback.tweak.y_offset_factor; + let mut defs = FontDefinitions::empty(); + defs.font_data.insert("Inter".into(), Arc::new(FontData::from_static(pdfcraft_fonts::INTER_REGULAR))); + defs.font_data.insert("fallback".into(), Arc::new(fallback)); + defs.families.insert(FontFamily::Proportional, vec!["Inter".into(), "fallback".into()]); + let mut fonts = Fonts::new(TextOptions::default(), defs); + let g = galley(&mut fonts, FontFamily::Proportional, size, 1.0, text); + let inter_has = |c: char| c.is_ascii(); + g.rows[0].glyphs.iter().map(|gl| gl.pos.y + if inter_has(gl.chr) { 0.0 } else { size * factor }).collect() + } + + #[test] + fn fallback_faces_share_the_interface_baseline() { + // Dancing Script's metrics differ from Inter's the way a Chinese face's do (by 0.044 em), + // and it has letters Inter doesn't (DŽ), which egui then takes from it. + let dancing: &'static [u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf"); + let size = 100.0; + // As egui lays it out by itself, DŽ sits ~4 pt off H's baseline: the bug (#1676). + let raw = baselines(FontData::from_static(dancing), size, "HDŽ"); + assert!((raw[1] - raw[0]).abs() > 3.0, "the test fonts must differ: {raw:?}"); + let drop = baseline_drop(vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0).unwrap(), vertical_metrics(dancing, 0).unwrap()); + assert!((raw[1] - raw[0] - size * drop).abs() <= 1.0, "baseline_drop predicts egui: {raw:?} vs {drop}"); + // On the interface baseline, to the pixel. + let aligned = baselines(on_interface_baseline(FontData::from_static(dancing)), size, "HDŽ"); + assert!((aligned[1] - aligned[0]).abs() <= 1.0, "{aligned:?}"); + // A face that doesn't parse is left alone rather than guessed at. + assert_eq!(on_interface_baseline(FontData::from_static(b"not a font")).tweak, FontTweak::default()); + assert_eq!(vertical_metrics(b"", 0), None); + } + #[test] fn windows_hinting_changes_only_the_jetbrains_symmetric_rendering_field() { let baseline = font_definitions_for_platform(false, FontPolicyPlatform::Other); From 0f86ba905ff99096451f4b9f4eb9babf69d6135f Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:22:31 +0500 Subject: [PATCH 037/207] M7: fold repeated image transforms into one cm instead of nesting q (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving, resizing, rotating or flipping a figure wrapped it in a fresh `q cm … Q` every time, so twenty drags left twenty nested graphics states and twenty `cm` operators around one `Do`. Each edit made the content stream longer and pushed the figure closer to the nesting limit viewers enforce (PDF 32000-1 Annex C recommends 28 levels). When the placement already sits alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added), `transform_image` and `translate_images` now change that `cm` in place: the CTM inside is `local · cm · outer` either way (8.4.4, 8.3.4), so the result is identical. A bare `cm Do` without a `q`, a shared clip or any other content in the scope still gets the wrapper, once. The new test runs twenty resizes and twenty group moves on a plain placement, one with a crop exclusive to the image, and a bare `cm Do`, and checks the visible rectangle after each step, that the `q` depth stays at its first-edit value, and that the following text is untouched. It fails without this change ("twenty resizes nest no deeper than one"). Co-authored-by: Claude Opus 5.5 --- crates/edit/src/images.rs | 42 ++++++++++++++++++++++++++++++ crates/edit/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++++++ docs/editing-images.md | 3 +++ 3 files changed, 100 insertions(+) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 85ae6b391b..83f6c108d0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -420,6 +420,19 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> { Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?))) } +/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced +/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same +/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each. +fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> { + let at = start.checked_sub(1)?; + let cm = ops.get(at)?; + if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 { + return None; + } + let m = Matrix(cm.nums::<6>()?); + m.0.iter().all(|v| v.is_finite()).then_some((at, m)) +} + /// The `cm` to put before the clip so that user-space transform `t` also moves the clip: /// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`). fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { @@ -603,6 +616,20 @@ fn transform_image( if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform is too large".into())); } + // Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added): + // change that `cm` in place. The CTM inside is `local · cm · outer` either way. + if let Some((at, cm)) = enclosing_transform(&ops, start, end) { + let folded = local.then(&cm); + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image transform is too large".into())); + } + let changed = crate::text::splice(&pieces, &ops, |index| { + if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) } + }); + let contents = crate::text::rewritten_contents(doc, streams, changed); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; + return Ok(()); + } let changed = crate::text::splice(&pieces, &ops, |index| { if index == start { let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())]; @@ -672,10 +699,25 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize plans.remove(&(stream, index)); clips.insert((stream, at), c); } + // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. + let mut folds = std::collections::BTreeMap::new(); + for index in 0..ops.len() { + let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue }; + let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; + let folded = movement.then(&cm); + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + plans.remove(&(stream, index)); + folds.insert(at, folded); + } let changed = crate::text::splice(&pieces, &ops, |index| { if let Some(c) = clips.remove(&(stream, index)) { return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); } + if let Some(m) = folds.remove(&index) { + return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false); + } let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) }; let original = ops.get(index).cloned().into_iter(); // Local translations can be tiny under a large CTM. Preserve their precision diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ecbedb9b4f..5c555814a3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1630,6 +1630,61 @@ fn close(a: [f64; 4], b: [f64; 4]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) } +/// The deepest `q` nesting on the page. +fn q_depth(doc: &Document) -> usize { + let mut depth = 0_usize; + let mut deepest = 0; + for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops { + if op.is("q") { + depth += 1; + deepest = deepest.max(depth); + } else if op.is("Q") { + depth = depth.saturating_sub(1); + } + } + deepest +} + +#[test] +fn repeated_image_edits_change_one_transform_instead_of_nesting() { + let variants = [ + // Its own placement, a private crop, and a bare `Do` that the first edit wraps. + "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET", + "q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET", + "200 0 0 100 100 100 cm /Im0 Do", + ]; + for content in variants { + let mut doc = image_page_with(content); + let resize = |doc: &mut Document, n: usize| -> usize { + let mut depth = 0; + for i in 0..n { + let r = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap(); + // Grow by 3% about the lower-left corner and move right by 2, staying on the page. + let target = [r[0] + 2.0, r[1], r[0] + 2.0 + (r[2] - r[0]) * 1.03, r[1] + (r[3] - r[1]) * 1.03]; + images::change_image(doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(r, target))).unwrap(); + let after = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap(); + assert!(after.iter().zip(target).all(|(a, b)| (a - b).abs() < 1e-6), "{content}: {after:?} vs {target:?}"); + if i == 0 { + depth = q_depth(doc); + } + } + depth + }; + let first = resize(&mut doc, 20); + assert_eq!(q_depth(&doc), first, "{content}: twenty resizes nest no deeper than one"); + let mut doc = reopen(&doc); + let before = images::page_images(&doc, 0).unwrap()[0].rect; + for _ in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + } + let after = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(after, [before[0] + 20.0, before[1] - 40.0, before[2] + 20.0, before[3] - 40.0]), "{content}: {after:?}"); + assert_eq!(q_depth(&doc), first, "{content}: group moves change the same transform"); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption.is_empty(), !content.contains("Caption"), "{content}: the text after the image is untouched"); + } +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); diff --git a/docs/editing-images.md b/docs/editing-images.md index 3417b4c6d4..eba918ef39 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -6,6 +6,9 @@ that placement. The context menu also rotates or flips it. Changes support undo survive saving/reopening. For single-figure transforms, a clipping path exclusive to one image moves and resizes together with its pixels, including nested graphics states, intervening state operators and paths split across content streams. A clip shared with other content stays where it is. +When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an +earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't +nest one more graphics state each time. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects From 7c2ca46d2f94d5cdb998b8a21f1bf7fe33efadf5 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:23:03 +0800 Subject: [PATCH 038/207] i18n: Taiwan vocabulary and the missing entries in zh-hant (#1750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: Taiwan vocabulary and the missing entries in zh-hant The Traditional Chinese catalog says it uses the vocabulary of Taiwan, but 113 entries used mainland terms, often next to the Taiwan term for the same word elsewhere in the file (對話框 and 對話方塊, 應用 and 套用 for Apply, 列表 and 清單). They now read 套用, 捨棄, 對話方塊, 另存新檔為, 自訂, 目前, 清單/清單方塊, 置中, 平面化, 選取, 送出 (submit), 浮水印, 醒目提示, 一般, 進階, 重設, 存取, 全螢幕, 範例, 標題列, 工具列, 收件者 and 字型大小, and quotations use 「」 instead of “”. Application (應用程式) and Commit (提交) keep their words. The 80 entries the Simplified Chinese catalog has and this one lacked (About ▸ Contributors and Models, link and email confirmations, preferences defaults, Create PDF from images, print scale notes, Fill & Sign) are translated from the English, so those labels no longer show in English. Reviewed by a native Taiwanese speaker. Co-Authored-By: Claude Opus 5.5 * i18n: zh-hant Grab bag reads 精簡 The contributor list's compact view keeps the chosen sort order, so it is named for its look (精簡, against 表格), not as an unordered pile. Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 306 ++++++++++++++++++---------- 2 files changed, 195 insertions(+), 115 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 5e17018c31..763dc4a212 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "d7de75508c3637033c93886b742245f903a6f806bab208ff295bf57e7707e7bb" +sha256 = "8ec618949e296e540df72051ff284e5d082f3cd999202f727660f91a7dd3c7b9" [[asset]] path = "crates/ui-egui/src/i18n/cs.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 = "471209d1828dafb11c117260ff53616d580cec02fecd37f6685ba7f200b2549e" +sha256 = "714af0e8635275baec6f8a07dd96fc1976dfa5269d462549446985a8786bbb21" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7e49a5e69c..6f6d0ca2f7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -102,15 +102,15 @@ Close 關閉 Don't save 不儲存 Delete 刪除 - Apply 應用 + Apply 套用 Run 執行 Clear 清除 Sanitize 清理 Mark all 全部標記 - Save changes to “{name}” before closing? 在關閉前儲存對“{name}”的更改? + Save changes to “{name}” before closing? 在關閉前儲存對「{name}」的更改? Your changes will be lost if you don't save them. 如果不儲存,更改將會丟失。 Password required 需要密碼 - “{name}” is protected. Enter a password to open it. “{name}”受保護。請輸入密碼以開啟。 + “{name}” is protected. Enter a password to open it. 「{name}」受保護。請輸入密碼以開啟。 Password 密碼 Incorrect password. Try again. 密碼不正確,請重試。 Export a PDF 匯出 PDF @@ -147,7 +147,7 @@ Review 審閱 Recognize text 辨識文字 Recognize Text 辨識文字 - Password protection will be applied when you save 儲存時將應用密碼保護 + Password protection will be applied when you save 儲存時將套用密碼保護 Saved the print-ready PDF to {path} 列印就緒的 PDF 已儲存到{path} Could not save: {e} 無法儲存:{e} Earlier in tab order 在 Tab 順序中前移 @@ -156,7 +156,7 @@ To: 至: High Resolution 高解析度 Print 列印 - Save as PDF/A 另存為 PDF/A + Save as PDF/A 另存新檔為 PDF/A Verify to see what the document needs. 驗證以檢視文件需要什麼。 Set Page Boxes 設定頁面框 Signature panel 簽名面板 @@ -179,14 +179,14 @@ November 十一月 December 十二月 Done 完成 - This certificate is in your list of trusted certificates. 此憑證位於受信任憑證列表中。 + This certificate is in your list of trusted certificates. 此憑證位於受信任憑證清單中。 This certificate is trusted through a certificate above it in the chain. 此憑證透過鏈中上方的憑證受到信任。 This certificate is not trusted. Signatures made with it show an unknown identity. 此憑證不受信任。使用它製作的簽名顯示未知身份。 Add to Trusted Certificates 新增到受信任的憑證 Export… 匯出… PDF Optimizer PDF 最佳化器 - Discard Objects 丟棄物件 - Discard User Data 丟棄使用者資料 + Discard Objects 捨棄物件 + Discard User Data 捨棄使用者資料 Clean Up 清理 Color Images 彩色圖片 Grayscale Images 灰度圖片 @@ -200,18 +200,18 @@ High 高 Maximum 最高 Each image is measured where pages draw it; an image is replaced only if the result is smaller. 按頁面繪製位置測量每個圖片;僅在結果更小時替換圖片。 - Discard all links, actions and JavaScript 丟棄所有連結、動作和 JavaScript - Discard alternate images 丟棄備用圖片 - Discard embedded page thumbnails 丟棄嵌入的頁面縮圖 - Discard document tags 丟棄文件標記 - Discard embedded print settings 丟棄嵌入的列印設定 - Discard bookmarks 丟棄書籤 - Discard hidden layer content 丟棄隱藏圖層內容 - Discard all comments, forms and multimedia 丟棄所有註解、表單和多媒體 - Discard document information and metadata 丟棄文件資訊和中繼資料 - Discard all object data (file attachments) 丟棄所有物件資料(檔案附件) - Discard private data of other applications 丟棄其他應用程式的私有資料 - Discard hidden text 丟棄隱藏文字 + Discard all links, actions and JavaScript 捨棄所有連結、動作和 JavaScript + Discard alternate images 捨棄備用圖片 + Discard embedded page thumbnails 捨棄嵌入的頁面縮圖 + Discard document tags 捨棄文件標記 + Discard embedded print settings 捨棄嵌入的列印設定 + Discard bookmarks 捨棄書籤 + Discard hidden layer content 捨棄隱藏圖層內容 + Discard all comments, forms and multimedia 捨棄所有註解、表單和多媒體 + Discard document information and metadata 捨棄文件資訊和中繼資料 + Discard all object data (file attachments) 捨棄所有物件資料(檔案附件) + Discard private data of other applications 捨棄其他應用程式的私有資料 + Discard hidden text 捨棄隱藏文字 Use Flate to encode streams that are not encoded 使用 Flate 編碼未編碼的流 Remove invalid links and bookmarks 刪除無效連結和書籤 Remove unreferenced named destinations 刪除未引用的命名目標 @@ -236,7 +236,7 @@ Check Box Properties 核取方塊屬性 Radio Button Properties 選項按鈕屬性 Dropdown Properties 下拉選單屬性 - List Box Properties 列表框屬性 + List Box Properties 清單方塊屬性 Button Properties 按鈕屬性 Digital Signature Properties 數位簽章屬性 Position 位置 @@ -246,10 +246,10 @@ Actions 操作 This field is locked. Unlock it to change its properties. 此欄位已鎖定。解鎖後可更改其屬性。 Tooltip: 提示: - Common Properties 常規屬性 + Common Properties 一般屬性 Read Only 唯讀 Required 必填 - Font Size: 字號: + Font Size: 字型大小: Auto 自動 Text Color: 文字顏色: Borders and Colors 邊框和顏色 @@ -274,9 +274,9 @@ Check spelling 檢驗拼字 Comb of 梳狀(每格一字元),共 Export Value: {on} 匯出值:{on} - Check box is checked by default 預設選中核取方塊 - Button is checked by default 預設選中按鈕 - Buttons with the same name and value are selected in unison 同名同值的按鈕一起選中 + Check box is checked by default 預設選取核取方塊 + Button is checked by default 預設選取按鈕 + Buttons with the same name and value are selected in unison 同名同值的按鈕一起選取 Item: 項目: Item List 項目清單 Up 上移 @@ -285,7 +285,7 @@ Default: 預設值: (none) (無) Sort items 排序項目 - Allow user to enter custom text 允許使用者輸入自定義文字 + Allow user to enter custom text 允許使用者輸入自訂文字 Multiple selection 多選 Commit selected value immediately 立即提交所選值 Number 數字 @@ -300,7 +300,7 @@ Before the number 在數字之前 Negative number style: 負數樣式: Format: 格式: - Custom: 自定義: + Custom: 自訂: Zip Code 郵政編碼 Zip Code + 4 郵政編碼 + 4 Phone Number 電話號碼 @@ -308,7 +308,7 @@ Arbitrary Mask 任意掩碼 Mask: 掩碼: 9 digit, A letter, O letter or digit, X any character 9 表示數字,A 表示字母,O 表示字母或數字,X 表示任意字元 - Example: {sample} 示例:{sample} + Example: {sample} 範例:{sample} Field value is not validated 不驗證欄位值 Field value is in range: 欄位值在範圍內: Add an Action 新增操作 @@ -321,15 +321,15 @@ On Blur 失去焦點 Select Action: 選擇操作: Open a web link 開啟網頁連結 - Reset a form 重置表單 + Reset a form 重設表單 Execute a menu item 執行選單項 Show a field 顯示欄位 Hide a field 隱藏欄位 - Submit a form 提交表單 - Fields to reset (comma-separated; empty: all) 要重置的欄位(逗號分隔;為空表示全部) + Submit a form 送出表單 + Fields to reset (comma-separated; empty: all) 要重設的欄位(逗號分隔;為空表示全部) Print, NextPage, PrevPage, FirstPage or LastPage Print、NextPage、PrevPage、FirstPage 或 LastPage Field names (comma-separated) 欄位名(逗號分隔) - URL to submit to 要提交到的 URL + URL to submit to 要送出到的 URL Script: 指令碼: Value: 值: No actions 無操作 @@ -352,7 +352,7 @@ Bold 粗體 Italic 斜體 Align left 左對齊 - Centre 居中 + Centre 置中 Align right 右對齊 Justify 兩端對齊 Thai and Arabic use regular fallback fonts when available. 泰語和阿拉伯語使用可用的一般樣式備用字型。 @@ -379,14 +379,14 @@ Create {what} 建立{what} initials 姓名首字母 signature 簽名 - Create Custom Stamp 建立自定義戳記 + Create Custom Stamp 建立自訂戳記 From {file} 來自 {file} Category: 類別: e.g. My stamps 例如:我的戳記 Name: 名稱: - Custom 自定義 + Custom 自訂 Delete stamp 刪除戳記 - Create custom stamp… 建立自定義戳記… + Create custom stamp… 建立自訂戳記… From a PDF page or an image 來自 PDF 頁面或圖片 Line spacing 行距 Character spacing 字元間距 @@ -396,7 +396,7 @@ Flip Horizontal 水平翻轉 Flip Vertical 垂直翻轉 Replace Image… 替換圖片… - Save Image As… 圖片另存為… + Save Image As… 圖片另存新檔… Copy Image 複製圖片 The image has been copied ({w} × {h} pixels) 圖片已複製({w} × {h} 畫素) Couldn't copy the image: {e} 無法複製圖片:{e} @@ -407,10 +407,10 @@ No check has run yet. 尚未執行檢驗。 Checking for a newer version… 正在檢驗新版本… PdfCraft {v} is available. PdfCraft {v} 可用。 - You have version {c}. Download the new version from its release page. 當前版本為 {c}。請從其釋出頁面下載新版本。 + You have version {c}. Download the new version from its release page. 目前版本為 {c}。請從其釋出頁面下載新版本。 PdfCraft {c} is up to date. PdfCraft {c} 已是最新。 Couldn't check for updates: {e} 無法檢驗更新:{e} - You have version {c}. All releases are listed at {page}. 當前版本為 {c}。所有版本列在 {page}。 + You have version {c}. All releases are listed at {page}. 目前版本為 {c}。所有版本列在 {page}。 Asks GitHub for the latest release. Nothing is downloaded or installed automatically. 向 GitHub 搜尋最新版本。不會自動下載或安裝任何內容。 Download 下載 Later 稍後 @@ -425,19 +425,19 @@ Box 框 Units 單位 Set to zero 置零 - Current page ({n}) 當前頁(第 {n} 頁) + Current page ({n}) 目前頁(第 {n} 頁) Subset 子集 Update 更新 Add 新增 {verb} Header and Footer {verb}頁首和頁尾 - {verb} Watermark {verb}水印 + {verb} Watermark {verb}浮水印 {verb} Background {verb}背景 Margins 邊距 Left Header Text 左頁首文字 - Center Header Text 居中頁首文字 + Center Header Text 置中頁首文字 Right Header Text 右頁首文字 Left Footer Text 左頁尾文字 - Center Footer Text 居中頁尾文字 + Center Footer Text 置中頁尾文字 Right Footer Text 右頁尾文字 Insert Page Number 插入頁碼 Insert Date 插入日期 @@ -450,8 +450,8 @@ From colour 使用顏色 Page Range Options 頁面範圍選項 Preview: page {p} of {n} 預覽:第 {p} 頁,共 {n} 頁 - Applies to 1 page 應用於 1 頁 - Applies to {n} pages 應用於 {n} 頁 + Applies to 1 page 套用於 1 頁 + Applies to {n} pages 套用於 {n} 頁 Replaces the existing one on those pages. 替換這些頁面上的現有內容。 PDF or image PDF 或圖片 No file chosen 未選擇檔案 @@ -463,7 +463,7 @@ Link Type: 連結型別: Visible Rectangle 可見矩形 Invisible Rectangle 不可見矩形 - Highlight Style: 高亮樣式: + Highlight Style: 醒目提示樣式: Invert 反轉 Outline 輪廓 Inset 嵌入 @@ -507,7 +507,7 @@ File (.p12, .pfx) 檔案 (.p12, .pfx) Digital ID 數位 ID Browse… 瀏覽… - {verb} as "{name}" {verb}為“{name}” + {verb} as "{name}" {verb}為「{name}」 Certify 認證 The signature will be invisible. 簽名將不可見。 Distinguished name 可分辨名稱 @@ -543,7 +543,7 @@ Show certificate… 顯示憑證… Export certificate… 匯出憑證… Certificate Viewer 憑證檢視器 - This dialog shows the details of a certificate and its chain. 此對話框顯示憑證及其鏈的詳細資訊。 + This dialog shows the details of a certificate and its chain. 此對話方塊顯示憑證及其鏈的詳細資訊。 Trust 信任 Issued to 頒發給 Valid from 有效期自 @@ -569,19 +569,19 @@ An action with this name already exists. 已存在同名的操作。 New Action… 新建操作… Edit… 編輯… - Add watermark 新增水印 + Add watermark 新增浮水印 Add header 新增頁首 Add footer 新增頁尾 Set document title 設定文件標題 Run a JavaScript 執行 JavaScript Prepare for Distribution 準備分發 - Remove hidden information and flatten comments and form fields, so recipients see only the pages. 刪除隱藏資訊並拼合註解和表單欄位,使接收者僅看到頁面。 + Remove hidden information and flatten comments and form fields, so recipients see only the pages. 刪除隱藏資訊並平面化註解和表單欄位,使收件者僅看到頁面。 Optimize Scanned Documents 最佳化掃描文件 Make scans searchable, then make them smaller. 使掃描件可搜尋,然後將其變小。 Add Page Numbers 新增頁碼 Number every page in the footer. 在頁尾為每頁編號。 Mark as Confidential 標為機密 - A diagonal CONFIDENTIAL watermark on every page. 每頁上的對角線 CONFIDENTIAL 水印。 + A diagonal CONFIDENTIAL watermark on every page. 每頁上的對角線 CONFIDENTIAL 浮水印。 Sanitize Documents 清理文件 Remove hidden information, metadata, scripts and embedded content, and rewrite the files. 刪除隱藏資訊、中繼資料、指令碼和嵌入內容,並重寫檔案。 Type text 輸入文字 @@ -591,7 +591,7 @@ Add a sticky note here 在此處新增附註 {kind} Properties {kind} 屬性 Appearance 外觀 - General 常規 + General 一般 Review History 審閱歷史 This comment's appearance can't be changed yet. 此註解的外觀尚無法更改。 Icon 圖示 @@ -603,7 +603,7 @@ Open the older version first 請先開啟舊版本 New file: 新檔案: Text is compared word by word; the differences are shown on the new file. 逐詞比較文字;差異顯示在新檔案上。 - Compare this file with an older version: Compare files… in All tools. 將此檔案與舊版本比較:在所有工具中使用“比較檔案…”。 + Compare this file with an older version: Compare files… in All tools. 將此檔案與舊版本比較:在所有工具中使用「比較檔案…」。 No differences in the text. 文字沒有差異。 {n} {kind} {n} {kind} Inserted 插入 @@ -630,7 +630,7 @@ Page Range 頁面範圍 All pages in document 文件中的所有頁面 Checking Options ({on} of 32 in all categories) 檢驗選項(所有類別共 32 項,已選 {on} 項) - Show this dialog when the Checker starts 啟動檢驗器時顯示此對話框 + Show this dialog when the Checker starts 啟動檢驗器時顯示此對話方塊 Select All 全選 Clear All 全部清除 Start Checking 開始檢驗 @@ -661,11 +661,11 @@ Forms 表單 Alternate Text 替代文字 Tables 表格 - Lists 列表 + Lists 清單 Headings 標題 Accessibility permission flag 無障礙許可標誌 Accessibility permission flag is set 已設定無障礙許可標誌 - Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. 安全設定必須允許輔助技術讀取內容(“允許螢幕閱讀器裝置訪問文字”)。請更改文件安全設定以允許。 + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. 安全設定必須允許輔助技術讀取內容(「允許螢幕閱讀器裝置存取文字」)。請更改文件安全設定以允許。 Image-only PDF 純圖片 PDF Document is not image-only PDF 文件不是純圖片 PDF Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. 僅為文字圖片的頁面無法朗讀或搜尋。請辨識文字(OCR)以新增文字層。 @@ -676,9 +676,9 @@ Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. 檢驗標記的閱讀順序是否與明眼讀者一致。使用閱讀順序工具或標記面板進行更正。 Primary language 主要語言 Text language is specified 已指定文字語言 - Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. 螢幕閱讀器根據文件語言選擇語音。請在“文件屬性”>“高階”>“語言”中設定。 - Document title is showing in title bar 標題欄中顯示文件標題 - The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. 視窗應顯示有意義的標題而非檔案名。請設定標題,並在“初始檢視”>“顯示”中選擇“文件標題”。 + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. 螢幕閱讀器根據文件語言選擇語音。請在「文件屬性」>「進階」>「語言」中設定。 + Document title is showing in title bar 標題列中顯示文件標題 + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. 視窗應顯示有意義的標題而非檔案名。請設定標題,並在「初始檢視」>「顯示」中選擇「文件標題」。 Bookmarks are present in large documents 大文件中有書籤 Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. 超過 20 頁的文件需要書籤以便導覽。請為主要章節新增書籤。 Color contrast 顏色對比度 @@ -692,7 +692,7 @@ Comments, links and fields must be in the tag tree to be announced. Tag the annotations. 註解、連結和欄位必須位於標記樹中才能被播報。請標記註解。 Tab order Tab 順序 Tab order is consistent with structure order Tab 順序與結構順序一致 - Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. 包含連結、註解或欄位的頁面應按結構順序 Tab。修復操作將每頁的 Tab 順序設為“使用文件結構”。 + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. 包含連結、註解或欄位的頁面應按結構順序 Tab。修復操作將每頁的 Tab 順序設為「使用文件結構」。 Character encoding 字元編碼 Reliable character encoding is provided 提供了可靠的字元編碼 Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. 字型必須將其字元對映到 Unicode,以便讀取和複製文字。請修復字型或重新生成 PDF。 @@ -715,7 +715,7 @@ Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. 表單欄位必須位於標記樹(表單標記)中才能被播報。請標記表單欄位。 Field descriptions 欄位描述 All form fields have description 所有表單欄位都有描述 - Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. 每個欄位都需要說明輸入內容的提示。在“欄位屬性”>“常規”>“提示”中新增。 + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. 每個欄位都需要說明輸入內容的提示。在「欄位屬性」>「一般」>「提示」中新增。 Figures alternate text 圖形替代文字 Figures require alternate text 圖形需要替代文字 Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. 圖片需要描述性的替代文字,或標為裝飾性。請為每個圖形新增替代文字。 @@ -743,12 +743,12 @@ Summary 摘要 Tables must have a summary 表格必須有摘要 A summary describes the table's purpose and layout. Add a Summary attribute to each table. 摘要描述表格的用途和佈局。請為每個表格新增 Summary 屬性。 - List items 列表項 + List items 清單項 LI must be a child of L LI 必須是 L 的子級 - List items belong in a list. Fix the list's tags. 列表項應位於列表內。請修復列表標記。 + List items belong in a list. Fix the list's tags. 清單項應位於清單內。請修復清單標記。 Lbl and LBody Lbl 和 LBody Lbl and LBody must be children of LI Lbl 和 LBody 必須是 LI 的子級 - List labels and bodies belong in a list item. Fix the list's tags. 列表標籤和正文應位於列表項內。請修復列表標記。 + List labels and bodies belong in a list item. Fix the list's tags. 清單標籤和正文應位於清單項內。請修復清單標記。 Appropriate nesting 適當的巢狀 Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. 標題級別不應跳躍(H1 後面不應直接跟 H3)。請重新標記標題。 TH and TD TH 和 TD @@ -775,18 +775,18 @@ Redaction Tool Properties 遮蔽內容工具屬性 Redacted area fill colour: 遮蔽內容區域填充顏色: Use overlay text 使用覆蓋文字 - Custom text: 自定義文字: + Custom text: 自訂文字: Redaction code: 遮蔽代碼: Font: 字型: - Font size: 字號: + Font size: 字型大小: Auto-size text to fit redaction region 自動調整文字大小以適應遮蔽內容區域 Font colour: 字型顏色: Repeat overlay text 重複覆蓋文字 Text alignment: 文字對齊: - Center 居中 - Apply redactions 應用遮蔽內容 - You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. 即將應用 1 個遮蔽內容標記。標記下的文字、圖片和繪圖,以及與其重疊的註解和表單欄位,將被永久刪除。 - You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. 即將應用 {n} 個遮蔽內容標記。標記下的文字、圖片和繪圖,以及與其重疊的註解和表單欄位,將被永久刪除。 + Center 置中 + Apply redactions 套用遮蔽內容 + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. 即將套用 1 個遮蔽內容標記。標記下的文字、圖片和繪圖,以及與其重疊的註解和表單欄位,將被永久刪除。 + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. 即將套用 {n} 個遮蔽內容標記。標記下的文字、圖片和繪圖,以及與其重疊的註解和表單欄位,將被永久刪除。 Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. 之後請儲存文件:儲存將重寫整個檔案,使刪除的內容不留痕跡。 Select the items to remove from this document. 選擇要從本文件中刪除的專案。 Metadata 中繼資料 @@ -797,12 +797,12 @@ Links, actions and JavaScripts 連結、動作和 JavaScript Private data of other applications 其他應用程式的私有資料 None found 未發現 - Form fields are flattened: their values stay visible. Saving rewrites the whole file. 表單欄位將被拼合:其值保持可見。儲存將重寫整個檔案。 + Form fields are flattened: their values stay visible. Saving rewrites the whole file. 表單欄位將被平面化:其值保持可見。儲存將重寫整個檔案。 Remove 刪除 - Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. 清理將刪除文件中的隱藏資訊:中繼資料、檔案附件、註解、表單欄位(拼合)、隱藏文字和圖層、書籤、連結、動作和指令碼,以及私有應用程式資料。 + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. 清理將刪除文件中的隱藏資訊:中繼資料、檔案附件、註解、表單欄位(平面化)、隱藏文字和圖層、書籤、連結、動作和指令碼,以及私有應用程式資料。 Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. 之後請儲存文件;儲存將重寫整個檔案,使刪除的內容不殘留。 Printer: 列印機: - Save as PDF 另存為 PDF + Save as PDF 另存新檔為 PDF {name} (default) {name}(預設) Copies: 份數: Collate 逐份列印 @@ -825,7 +825,7 @@ Booklet 小冊子 Fit 適合 Shrink oversized pages 縮小過大的頁面 - Custom scale: 自定義比例: + Custom scale: 自訂比例: Tile scale: 平鋪比例: Overlap: 重疊: Cut marks 裁切標記 @@ -860,8 +860,8 @@ Removed 1 hidden item. Save to remove them from the file. 已刪除 1 個隱藏項。儲存以從檔案中刪除它們。 Removed {n} hidden items. Save to remove them from the file. 已刪除 {n} 個隱藏項。儲存以從檔案中刪除它們。 Document sanitized. Save to finish: saving rewrites the whole file. 文件已清理。儲存以完成:儲存將重寫整個檔案。 - Applied 1 redaction mark. Save to remove the content from the file. 已應用 1 個遮蔽內容標記。儲存以從檔案中刪除內容。 - Applied {n} redaction marks. Save to remove the content from the file. 已應用 {n} 個遮蔽內容標記。儲存以從檔案中刪除內容。 + Applied 1 redaction mark. Save to remove the content from the file. 已套用 1 個遮蔽內容標記。儲存以從檔案中刪除內容。 + Applied {n} redaction marks. Save to remove the content from the file. 已套用 {n} 個遮蔽內容標記。儲存以從檔案中刪除內容。 JavaScript Console JavaScript 主控台 JavaScript is turned off (Preferences ▸ JavaScript). JavaScript 已關閉(偏好設定 → JavaScript)。 Output appears here. 輸出顯示在此處。 @@ -884,7 +884,7 @@ Use system setting 使用系統設定 Light gray 淺灰色 Dark gray 深灰色 - Current page 當前頁 + Current page 目前頁 Selected pages 選取的頁面 Document language 文件語言 Output 輸出 @@ -1177,7 +1177,7 @@ Set status 設定狀態 Copy text 複製文字 Properties… 屬性… - Make Current Properties Default 將當前屬性設為預設 + Make Current Properties Default 將目前屬性設為預設 More options 更多選項 Sort by 排序方式 Author 作者 @@ -1232,8 +1232,8 @@ Align 對齊 Top 上 Bottom 下 - Center horizontally 水平居中 - Center vertically 垂直居中 + Center horizontally 水平置中 + Center vertically 垂直置中 Distribute 分佈 Horizontally 水平分佈 Vertically 垂直分佈 @@ -1260,7 +1260,7 @@ Strong 強 Medium 中等 Weak 弱 - Advanced Options {chevron} 高階選項 {chevron} + Advanced Options {chevron} 進階選項 {chevron} Compatibility 相容性 Acrobat X and later (256-bit AES) Acrobat X 及更高版本(256 位 AES) Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 及更高版本(128 位 AES) @@ -1277,12 +1277,12 @@ Commenting, filling in form fields, and signing existing signature fields 註解、填寫表單欄位並簽署現有簽名欄位 Any except extracting pages 除提取頁面外的任何操作 Enable copying of text, images, and other content 允許複製文字、圖片和其他內容 - Enable text access for screen reader devices for the visually impaired 允許螢幕閱讀器裝置訪問文字以幫助視障人士 + Enable text access for screen reader devices for the visually impaired 允許螢幕閱讀器裝置存取文字以幫助視障人士 Type a password. 請輸入密碼。 The passwords don't match. 兩次輸入的密碼不一致。 This compatibility level supports only plain ASCII passwords. 此相容性級別僅支援純 ASCII 密碼。 - Anyone opening the document will need this password. It's applied when you save. 開啟文件的任何人都需要此密碼。儲存時應用。 - The document opens without a password; this password is needed to change the restrictions. It's applied when you save. 開啟文件無需密碼;更改限制需要此密碼。儲存時應用。 + Anyone opening the document will need this password. It's applied when you save. 開啟文件的任何人都需要此密碼。儲存時套用。 + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. 開啟文件無需密碼;更改限制需要此密碼。儲存時套用。 Recover unsaved documents? 要恢復未儲存的文件嗎? PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft 未正常關閉。以下文件的更改已自動儲存: just now 剛剛 @@ -1309,7 +1309,7 @@ Comments only 僅註解 Sort comments by: 註解排序依據: Create PDF Comment Summary 建立 PDF 註解摘要 - Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到“{name}”的上次儲存版本?此後的更改將無法復原。 + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到「{name}」的上次儲存版本?此後的更改將無法復原。 Number pages 頁面編號 Style 樣式 None (prefix only) 無(僅字首) @@ -1323,7 +1323,7 @@ Landscape Pages 橫向頁面 Portrait Pages 縱向頁面 Duplicate Field 複製欄位 - Duplicate "{name}" onto: 將“{name}”複製到: + Duplicate "{name}" onto: 將「{name}」複製到: 1 page selected. 已選擇 1 頁。 {n} pages selected. 已選擇 {n} 頁。 Delete pages after extracting 提取後刪除頁面 @@ -1364,7 +1364,7 @@ Initial View 初始檢視 Security 安全 Fonts 字型 - Advanced 高階 + Advanced 進階 Title 標題 Subject 主題 Keywords 關鍵字 @@ -1395,14 +1395,14 @@ of {pages} 共 {pages} 頁 Window Options 視窗選項 Resize window to initial page 調整視窗大小以適合初始頁面 - Center window on screen 在螢幕上居中視窗 - Open in Full Screen mode 以全屏模式開啟 + Center window on screen 在螢幕上置中視窗 + Open in Full Screen mode 以全螢幕模式開啟 Show: 顯示: File Name 檔案名 Document Title 文件標題 User Interface Options 使用者介面選項 Hide menu bar 隱藏選單欄 - Hide toolbars 隱藏工具欄 + Hide toolbars 隱藏工具列 Hide window controls 隱藏視窗控制元件 Security method 安全方法 No security 無安全性 @@ -1411,7 +1411,7 @@ Password security 密碼安全性 Encryption 加密 Opened with 開啟方式 - — (protection is applied when you save) —(儲存時應用保護) + — (protection is applied when you save) —(儲存時套用保護) Owner password (no restrictions apply) 所有者密碼(無限制) User password 使用者密碼 Change settings… 更改設定… @@ -1452,10 +1452,10 @@ On the web, open or drop an image to convert it 在 Web 端,開啟或拖入圖片進行轉換 Couldn't optimize the file: {e} 無法最佳化檔案:{e} Couldn't write {path}: {e} 無法寫入{path}:{e} - The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. 該表單請求提交到{u};PdfCraft 不傳送表單資料。請儲存文件以保留填寫內容。 + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. 該表單請求送出到{u};PdfCraft 不傳送表單資料。請儲存文件以保留填寫內容。 This PDF uses the unsupported {s} action. 此 PDF 使用了不支援的 {s} 動作。 {name}: the {other} action isn't supported yet {name}:{other} 動作暫不受支援 - {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} 將表單提交到{url};PdfCraft 不傳送表單資料。請儲存文件以保留填寫內容。 + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} 將表單送出到{url};PdfCraft 不傳送表單資料。請儲存文件以保留填寫內容。 Export stopped: {e} 匯出中止:{e} Exported 1 image 已匯出 1 張圖片 Exported {n} images 已匯出{n}張圖片 @@ -1510,7 +1510,7 @@ The document now conforms to {level}; save it to keep the changes 文件現已符合{level};儲存以保留更改 Fixed what could be fixed; 1 problem remains 已修復能修復的問題;還剩 1 個問題 Fixed what could be fixed; {n} problems remain 已修復能修復的問題;還剩{n}個問題 - Custom stamps arrive on the web with file pickers for images Web 端自定義戳記將使用圖片檔案選擇器 + Custom stamps arrive on the web with file pickers for images Web 端自訂戳記將使用圖片檔案選擇器 {file} is too large for a stamp (at most {n} MB) {file} 對於戳記來說太大了(最大 {n} MB) Sent to {printer} 已傳送到{printer} Sent to {printer}: {msg} 已傳送到{printer}:{msg} @@ -1643,17 +1643,17 @@ Nothing to undo 沒有可復原的操作 Nothing to redo 沒有可重做的操作 This document has no form fields you can fill in 本文件沒有可填寫的表單欄位 - This document has no comments to flatten 本文件沒有可拼合的註解 - This document has no form fields to flatten 本文件沒有可拼合的表單欄位 + This document has no comments to flatten 本文件沒有可平面化的註解 + This document has no form fields to flatten 本文件沒有可平面化的表單欄位 There are no redaction marks (mark text, areas or pages first) 沒有遮蔽內容標記(請先標記文字、區域或頁面) This document has no {kind} to change 本文件沒有可更改的{kind} header or footer 頁首或頁尾 - watermark 水印 + watermark 浮水印 background 背景 This document isn't password-protected 本文件未受密碼保護 Enter permissions password… 輸入許可權密碼… Permissions password 許可權密碼 - “{name}” has security restrictions. Enter its permissions password to lift them. “{name}”有安全限制。請輸入其許可權密碼以解除限制。 + “{name}” has security restrictions. Enter its permissions password to lift them. 「{name}」有安全限制。請輸入其許可權密碼以解除限制。 Restrictions lifted 已解除限制 Couldn't unlock {name}: {e} 無法解鎖 {name}:{e} This document has no restrictions to lift 此文件沒有可解除的限制 @@ -1663,14 +1663,14 @@ Security will be removed when you save 儲存時將刪除安全性設定 Click text or an image to edit it 按一下文字或圖片進行編輯 Drag a rectangle to create a link; double-click a link to edit it 拖動矩形建立連結;按兩下連結進行編輯 - There are no redaction marks to apply 沒有可應用的遮蔽內容標記 + There are no redaction marks to apply 沒有可套用的遮蔽內容標記 Drag to draw the area where the signature should appear. 拖動繪製簽名應顯示的區域。 Couldn't fit the visible content: {e} 無法適應可見內容:{e} Drag a rectangle around the area to copy 拖動框選要複製的區域 Click on the page to add a {tool}, or drag to set its size 按一下頁面新增{tool},或拖動設定其大小 Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes 拖動頁面上的矩形進行裁切;按兩下頁面可設定頁面框 - Keep tool selected: on 保持工具選中:開 - Keep tool selected 保持工具選中 + Keep tool selected: on 保持工具選取:開 + Keep tool selected 保持工具選取 Opacity 不透明度 Line thickness 線條粗細 What word or phrase would you like to search for? 您想搜尋哪個詞或短語? @@ -1696,8 +1696,8 @@ This document contains {n} interactive form fields. 本文件包含 {n} 個互動式表單欄位。 This file was damaged and has been repaired. Saving keeps the repaired version. 此檔案已損壞並已修復。儲存將保留修復後的版本。 Dismiss 關閉 - Hide field highlights 隱藏欄位高亮 - Highlight fields 高亮顯示欄位 + Hide field highlights 隱藏欄位醒目提示 + Highlight fields 醒目提示欄位 Security settings 安全設定 Details 詳情 Select (V) 選擇 (V) @@ -1712,7 +1712,7 @@ Copy 複製 Paste after 在後面貼上 Rename 重新命名 - Set to current page ({n}) 設為當前頁(第 {n} 頁) + Set to current page ({n}) 設為目前頁(第 {n} 頁) Move up 上移 Move down 下移 Indent 縮排 @@ -1792,8 +1792,8 @@ Spreadsheet (.xlsx) 試算表(.xlsx) Presentation (.pptx) 簡報(.pptx) PostScript / EPS PostScript / EPS - Save as PDF/X 另存為 PDF/X - Save as PDF/UA 另存為 PDF/UA + Save as PDF/X 另存新檔為 PDF/X + Save as PDF/UA 另存新檔為 PDF/UA JavaScript JavaScript pages 頁 Planned 規劃中 @@ -1963,9 +1963,9 @@ Set status Completed 設定為已完成 Set status None 清除狀態 Set status Unmarked 設定為未標記 - Save as PDF/A-1b 另存為 PDF/A-1b - Save as PDF/A-2b 另存為 PDF/A-2b - Save as PDF/A-3b 另存為 PDF/A-3b + Save as PDF/A-1b 另存新檔為 PDF/A-1b + Save as PDF/A-2b 另存新檔為 PDF/A-2b + Save as PDF/A-3b 另存新檔為 PDF/A-3b Fill & Sign: change signature 填寫與簽署:變更簽名 Fill & Sign: change initials 填寫與簽署:變更姓名縮寫 Fill & Sign: remove saved signature 填寫與簽署:刪除已儲存的簽名 @@ -2157,11 +2157,11 @@ animation effects Off 關 Greyscale 灰階 Black and white 黑白 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此印表機的驅動程式不接受其他程式的雙面列印設定,因此使用其自身設定。變更位置:屬性… - Browser's print dialog 瀏覽器的列印對話框 - Copies, two-sided printing and colour are chosen in the browser's print dialog. 份數、雙面列印和色彩在瀏覽器的列印對話框中選擇。 + Browser's print dialog 瀏覽器的列印對話方塊 + Copies, two-sided printing and colour are chosen in the browser's print dialog. 份數、雙面列印和色彩在瀏覽器的列印對話方塊中選擇。 Preparing {n} sheets for printing… 正在準備 {n} 張紙以供列印… Preparing sheet {done} of {total} for printing… 正在準備列印第 {done} 張,共 {total} 張… - Opening the browser's print dialog… 正在開啟瀏覽器的列印對話框… + Opening the browser's print dialog… 正在開啟瀏覽器的列印對話方塊… Couldn't print from the browser ({e}); downloaded {name} to print instead 無法從瀏覽器列印({e});已改為下載 {name} 以供列印 Tools panel side 工具面板位置 Add comment 新增註解 @@ -2231,3 +2231,83 @@ animation effects Off 關 Mark as artifact 標記為成品 Remove this leaf tag from reading order and mark its direct content as an artifact 從閱讀順序中移除此葉標記,並將其直接內容標記為成品 Mark content as artifact 將內容標記為成品 + About 關於 + Contributors 貢獻者 + Models 模型 + Show 顯示 + Username 使用者名稱 + Display name 顯示名稱 + Real name 真實姓名 + Sort 排序 + Name (A–Z) 名稱(A–Z) + Merged PRs 已合併的 PR + PRs PR + Commits 提交次數 + Lines added 新增行數 + Lines deleted 刪除行數 + Line delta 行數增減 + Binary assets added 新增的二進位資產 + Binary assets removed 移除的二進位資產 + First commit 首次提交 + Last commit 最近提交 + +LOC 新增行 + −LOC 刪除行 + ΔLOC 行數增減 + +Bin 新增二進位 + −Bin 移除二進位 + First 首次提交 + Last 最近提交 + Reverse the order 反轉順序 + Grab bag 精簡 + Table 表格 + {n} contributors · {c} commits {n} 位貢獻者 · {c} 次提交 + No contributor data was built into this copy. 此版本未內建貢獻者資料。 + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}:{prs} 個 PR、{commits} 次提交、+{added} / −{deleted} 行(Δ {delta})、+{bin_added} / −{bin_deleted} 個二進位資產,{first} – {last} + No model credits were built into this copy. 此版本未內建模型致謝資料。 + Company 公司 + Model 模型 + % of all commits 佔全部提交的百分比 + Lines +/− 行數 +/− + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. 請僅在信任此文件時繼續。網址可能夾帶文件中的資訊,例如您在表單中輸入的內容。 + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. 請單面列印。保持紙張順序,沿裁切標記裁切,再依由左至右、由上至下的順序疊放各疊紙張。 + Flatten Fill & Sign when saving 儲存時將填寫與簽署內容平面化 + Text, marks and signatures become part of the page. Other comments stay editable. 文字、標記和簽名會成為頁面的一部分。其他註解仍可編輯。 + Offset from centre 距中心的位移 + Line ending 線條端點 + Change {what} 變更{what} + Cut and stack 裁切後疊放 + Default workspace mode 預設工作區模式 + Default page display 預設頁面顯示 + Default zoom 預設縮放 + Documents and view 文件與檢視 + Open email app 開啟電子郵件應用程式 + Open link 開啟連結 + Open this web page? 要開啟此網頁嗎? + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. 指令碼在沙箱中執行,無法存取檔案或網路。指令碼要開啟網頁時,需要先經過您的允許。關閉 JavaScript 後,Acrobat 的標準格式、驗證和計算函式仍可運作。 + Sign yourself 自行簽署 + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 開啟 PDF 時使用。若啟動時或透過控制通道明確指定了模式,則本次工作階段以該模式為準。 + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. 在 PDF 未指定版面配置或縮放時使用。連續捲動不會在頁面之間自動對齊。 + Write this email? 要撰寫這封電子郵件嗎? + {who} in this document wants to open this address in your browser: 此文件中的{who}想在瀏覽器中開啟這個網址: + {who} in this document wants to open your email app with this address: 此文件中的{who}想用這個地址開啟您的電子郵件應用程式: + This web address mixes letters from different alphabets, a common way to imitate another site's name. 這個網址混用了不同字母系統的字母,這是仿冒其他網站名稱的常見手法。 + This web address uses letters from another alphabet, which can look like familiar ones. 這個網址使用了其他字母系統的字母,看起來可能和常見字母很像。 + Create PDF from images 從圖片建立 PDF + Selected image files: {count} 已選取的圖片檔案:{count} + Use image resolution 使用圖片解析度 + Use each image's embedded DPI; use 72 DPI when it is absent. 使用每張圖片內嵌的 DPI;沒有時使用 72 DPI。 + Use 72 DPI (one point per pixel) 使用 72 DPI(每個像素一點) + Use custom DPI 使用自訂 DPI + DPI sets the printed page size without resampling the image. DPI 決定列印時的頁面大小,不會重新取樣圖片。 + Create 建立 + Dynamic XFA form laid out from its template: {pages}, {fields}. 依範本排版的動態 XFA 表單:{pages}、{fields}。 + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA 表單:欄位與 XFA 資料會保持同步,因此其他檢視器也會顯示您填寫的內容。 +@plural {n} page|{n} pages {n} 頁 +@plural {n} field|{n} fields {n} 個欄位 + PDF page: {size} PDF 頁面:{size} + Match page at 100% 以 100% 符合頁面 + PDF scale: {scale}% PDF 比例:{scale}% + Printer paper and scaling may differ. Check driver settings. 印表機的紙張與縮放可能不同,請檢查驅動程式設定。 + Drawing scale changes. Use Actual size for full-scale plans. 圖面比例會改變。如需原尺寸的圖面,請使用「實際大小」。 + Done adding text 完成新增文字 + Discard this text (Esc) 捨棄此文字(Esc) From 8955ed8379ff0f313834d39939adb0d8f6aecf60 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:23:25 +0500 Subject: [PATCH 039/207] M7: keep the source operators of rows a text edit doesn't reach (#1754) Changing one character in a paragraph rewrote every row of it from the text state. A row whose width came from TJ kerning or justification gaps was then squeezed back to that width with a `Tz` stretch, and the TJ gaps became literal double spaces, so copying and searching saw "justified text". A typo fix in the last line of a justified paragraph stretched the glyphs of all the lines above it. `rewrite_styled_block` now compares each output row with the source row at the same position. When every character in it comes from the source unchanged, no style range covers it and the paragraph-wide formatting, width and position are untouched, that row keeps its own operators and only the other rows are drawn again. The new drawing goes where the first rewritten row was, so the existing ET/BT split restores the text matrix for the rows kept after it. Rows are rewritten as before when: - an earlier row wrapped and pushed them down (they no longer line up with a source row); - a rewritten row used `'` or `"`, whose move to the next line the rows after it depend on; - the first rewritten row is drawn in a different space from the paragraph's first row; - a source run contains a line break, so editor rows can't be paired with visual rows. Tests: a justified paragraph edited in its first, middle and last row keeps the other two rows' TJ operators byte for byte and every row's position; colouring a word in the last row keeps the rows above. The first test fails without this change. A second test pins the `'` case (a row-leading `'` that is rewritten would lift the kept rows by one line) and checks that a wrapping row shows each word once. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/edit/src/tests.rs | 96 +++++++++++++++++++++++++++++++++ crates/edit/src/text_regions.rs | 93 +++++++++++++++++++++++++++++--- docs/editing-existing-text.md | 5 ++ 3 files changed, 186 insertions(+), 8 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5c555814a3..d1ee5c6561 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3544,3 +3544,99 @@ fn page_marks_and_added_content_preserve_fractional_user_unit_matrices() { } } } + +#[test] +fn rows_the_edit_does_not_reach_keep_their_source_operators() { + // Word gaps in TJ, as justified text from a layout program has; the text state alone + // can't reproduce them, so a rewritten row would lose them. + let rows = [ + "[(justified) -400 ( text) -400 ( uses) -400 ( wide) -400 ( gaps)] TJ", + "[(between) -400 ( every) -400 ( single) -400 ( word) -400 ( here)] TJ", + "(last line.) Tj", + ]; + let content = format!("BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm {} T* {} T* {} ET", rows[0], rows[1], rows[2]); + let row_texts = |doc: &Document| -> Vec<(String, [f64; 2])> { + text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect() + }; + let source = row_texts(&text_page(&content)); + assert_eq!(source.len(), 3); + for (row, from, to) in [(2, "last line.", "last line!"), (0, "justified", "justify"), (1, "single", "lone")] { + let mut doc = text_page(&content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap(); + let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1); + text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let after = row_texts(&doc); + assert_eq!(after.len(), 3, "{to}: {after:?}"); + for (i, ((text, at), (old, was))) in after.iter().zip(&source).enumerate() { + assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: row {i} stays at {was:?}, not {at:?}"); + if i == row { + assert_eq!(*text, old.replacen(from, to, 1), "{to}: the edited row"); + assert!(!bytes.contains(rows[i]), "{to}: the edited row is rewritten"); + } else { + assert_eq!(text, old, "{to}: row {i}"); + assert_eq!(bytes.matches(rows[i]).count(), 1, "{to}: row {i} keeps its own operator, kerning included:\n{bytes}"); + } + } + } + // Colouring a selection in the last row leaves the rows above it as they were. + let mut doc = text_page(&content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap(); + let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.clone(); + let at = draft.find("line").unwrap(); + let range = text::RangeStyle { + range: at..at + 4, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle { ranges: vec![range], ..Default::default() }).unwrap(); + let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(rows[..2].iter().all(|row| bytes.matches(row).count() == 1) && !bytes.contains(rows[2]), "{bytes}"); + assert!(bytes.contains("1 0 0 rg"), "{bytes}"); + // The coloured word is its own run now, so only the rows above compare whole. + assert_eq!(row_texts(&reopen(&doc))[..2], source[..2]); +} + +#[test] +fn kept_rows_stay_in_place_when_rows_wrap_or_move_with_quote_operators() { + let lines_of = |doc: &Document| -> Vec<(String, [f64; 2])> { + text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect() + }; + let edit = |content: &str, from: &str, to: &str| { + let mut doc = text_page(content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.editor_text.contains(from)).unwrap(); + let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1); + text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap(); + reopen(&doc) + }; + // `'` moves to the next line as it shows: a row after a rewritten `'` row can't stay as it is. + for (quoted, from, to) in [ + ("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "second", "2nd"), + ("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "third", "3rd"), + // Here the first row's own `'` makes the step down that the rows after it build on. + ("BT /F1 12 Tf 14 TL 72 714 Td (first row here) ' (second row here) ' (third row here) ' ET", "first", "1st"), + ] { + let source = lines_of(&text_page(quoted)); + let after = lines_of(&edit(quoted, from, to)); + assert_eq!(after.len(), 3, "{to}: {after:?}"); + for ((text, at), (old, was)) in after.iter().zip(&source) { + assert_eq!(*text, old.replacen(from, to, 1)); + assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: {text} stays at {was:?}, not {at:?}"); + } + } + // A row that grows past the paragraph's width wraps and pushes the rows below it down; + // those are rewritten in their new places, never left behind as well. + let rows = "BT /F1 12 Tf 14.4 TL 72 700 Td (one two three four) Tj T* (five six seven eight) Tj T* (nine ten eleven twelve) Tj ET"; + let doc = edit(rows, "two", "two and a great many more words"); + let text: String = lines_of(&doc).into_iter().map(|(t, _)| t + " ").collect(); + for word in ["one", "five", "nine", "twelve"] { + assert_eq!(text.matches(word).count(), 1, "{word} appears once: {text}"); + } + assert!(lines_of(&doc).len() > 3, "the edited row wrapped: {text}"); +} diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 0213d9040b..6143496815 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -192,6 +192,46 @@ fn shape_source( Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) } +/// For each output row (a range of the draft's characters), whether it is exactly the source row +/// at the same position: the same characters, none of them inserted or changed by the edit. +fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { + let old: Vec = block.editor_text.chars().collect(); + let new: Vec = draft.chars().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + // The source character a draft character is, if the edit left it alone. + let origin = |i: usize| { + if i < prefix { + Some(i) + } else if i >= new.len().saturating_sub(suffix) { + (old.len() + i).checked_sub(new.len()) + } else { + None + } + }; + let mut source_rows = Vec::new(); + let mut start = 0; + for (i, ch) in old.iter().enumerate() { + if *ch == '\n' { + source_rows.push(start..i); + start = i + 1; + } + } + source_rows.push(start..old.len()); + // A line break inside a source run would pair editor rows with the wrong visual rows. + if source_rows.len() != block.rows.len() { + return vec![false; rows.len()]; + } + rows.iter() + .enumerate() + .map(|(r, range)| { + source_rows.get(r).is_some_and(|source| { + !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) + }) + }) + .collect() +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(super) fn rewrite_styled_block( @@ -227,6 +267,7 @@ pub(super) fn rewrite_styled_block( { return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); } + let source_count = lines.len(); let mut styled_lines = lines.to_vec(); let mut styled_spans = Vec::new(); let mut underlines = HashMap::new(); @@ -488,6 +529,9 @@ pub(super) fn rewrite_styled_block( Ok(width) }; let mut rows: Vec> = Vec::new(); + // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). + let mut row_ranges = Vec::new(); + let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; // Wrap at word boundaries, retaining explicit source row breaks and whitespace. @@ -495,9 +539,11 @@ pub(super) fn rewrite_styled_block( while at < chars.len() { let Some(item) = chars.get(at) else { break }; if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; at += 1; + row_start = at; continue; } let mut end = at + 1; @@ -512,17 +558,47 @@ pub(super) fn rewrite_styled_block( while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { row.pop(); } + row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; + row_start = at; } row.extend_from_slice(word); row_width += w; at = end; } if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - for row in &rows { + let streams = content_streams(doc, &p.dict); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and + // glyph widths stay exactly as they were instead of being rebuilt from the text state. + let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); + let mut kept = if plain { + untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() + } else { + vec![false; rows.len()] + }; + let kept_lines: std::collections::HashSet = + kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); + // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's + // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = rewritten.first().copied().filter(|lead| { + lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9) + && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))) + }); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|k| *k = false); + (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first) + } + }; + for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { return Err(EditError::Invalid( @@ -534,7 +610,7 @@ pub(super) fn rewrite_styled_block( let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; let mut shaped_runs = HashMap::new(); let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate() { + for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { let mut at = 0; while let Some(item) = row.get(at) { let mut end = at + 1; @@ -589,6 +665,11 @@ pub(super) fn rewrite_styled_block( baseline = anchor.origin.baseline; } let anchor = anchor.unwrap_or(first); + if kept.get(row_index).copied().unwrap_or(false) { + // Kept rows only occur without spacing or size changes, so this is the step below. + baseline -= source_leading; + continue; + } let w = measured.get(row_index).copied().unwrap_or(0.0); let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); @@ -692,14 +773,10 @@ pub(super) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); - let streams = content_streams(doc, &p.dict); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &first.origin; - let start = first.ops.first().copied().unwrap_or(o.bt_op); + let o = &lead.origin; + let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start) .unwrap_or_default() diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 3e71c8c72e..07104d79e0 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -4,6 +4,11 @@ Click a text region in **Edit a PDF → Edit text and images**. One editor conta source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; source font programs, colour, size and baseline anchors stay intact. A textless preview prevents a second copy underneath. The frame shows the live extent as text grows or shrinks. +Rows a text edit doesn't reach keep their original operators, so their kerning, `TJ` word +gaps and glyph widths stay exactly as they were. Only rows whose characters changed, or that +moved because an earlier row wrapped, are rewritten, and formatting a selection rewrites +only the rows it covers. Changing the whole paragraph's formatting, width or position rewrites +every row. A subset font contains only some of the original face's glyphs. A new character cannot be reconstructed from a glyph the PDF does not contain. **Load font…** imports an openly licensed, From 432d3172d4e1a3e1058b2664a369ea9d564081ba Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:23:47 +0500 Subject: [PATCH 040/207] M7: draw new characters with the source font's own embedded program (#1760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF that embeds the complete TrueType/OpenType program of a simple WinAnsi font can only encode 256 characters through that font, but the program usually has many more glyphs. Typing one the encoding lacks (ő, or Arabic in a face that has it) ignored the program: the edit went to the Type 3 fallback, so official builds drew the character with the Japanese face's Latin glyph, and builds without craft-fonts refused it with "this text needs PdfCraft's Japanese fallback font", even for a Latin letter. The row editor now looks at the paragraph font's own program after the source encoding, same-face resources and matching craft-fonts faces, and before the fallback. When it has the glyph, the program is embedded once per edit as a Unicode Type 0 font (the same embedding a loaded font gets), so the new character keeps the source face. A paragraph whose new characters only that program can draw takes the row editor's path. The program is used only when: - the font is not a subset (no `ABCDEF+` tag), since a subsetter can keep a character's mapping after dropping its outline; - `validate` accepts it, which includes the OS/2 fsType check, so a font licensed for preview & print or restricted embedding is not reused for editable text. When no face has a non-Japanese character, the error now names it and points to Load font / Installed fonts; Japanese text keeps its message. Tests: Inter embedded as a simple WinAnsi TrueType font, edited in a one-row paragraph (the single-style path) and in a row of a two-row one, draws ő from a Type 0 font whose program is Inter's bytes, with no Type 3 font and the word read back whole. The same program under a subset name is not used, and without craft-fonts the edit fails, naming "ő (U+0151)", and leaves the page unchanged. Both fail without this change. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/edit/src/tests.rs | 73 +++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 41 +++++++++++++++++- crates/edit/src/text_regions.rs | 23 +++++++++++ docs/editing-existing-text.md | 5 ++- 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d1ee5c6561..555e608df7 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3640,3 +3640,76 @@ fn kept_rows_stay_in_place_when_rows_wrap_or_move_with_quote_operators() { } assert!(lines_of(&doc).len() > 3, "the edited row wrapped: {text}"); } + +/// A page whose `/F1` is Inter embedded as a simple WinAnsi TrueType font, under `base_font`. +fn embedded_program_page(content: &str, base_font: &str) -> Document { + let mut doc = text_page(content); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"BaseFont".to_vec(), Object::name(base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + font.set(b"FirstChar".to_vec(), Object::Int(32)); + font.set(b"LastChar".to_vec(), Object::Int(126)); + font.set(b"Widths".to_vec(), Object::Array((32..=126).map(|_| Object::Int(560)).collect())); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + reopen(&doc) +} + +/// The font programs and subtypes of the page's font resources. +fn page_font_programs(doc: &Document) -> Vec<(Vec, Option>)> { + let page = pdfcraft_model::pages(doc).remove(0); + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let fonts = resources.get(b"Font").and_then(|f| doc.resolve(f).as_dict().cloned()).unwrap_or_default(); + fonts + .iter() + .filter_map(|(_, f)| doc.resolve(f).as_dict().cloned()) + .map(|f| (f.name(b"Subtype").unwrap_or_default().to_vec(), pdfcraft_fonts::pdf::embedded_sfnt(doc, &f))) + .collect() +} + +#[test] +fn a_new_character_comes_from_the_source_fonts_own_embedded_program() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + for (content, from, to) in [ + // One row (the single-style path) and a row of a two-row paragraph (the row editor's path). + ("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "text", "tőxt"), + ("BT /F1 12 Tf 14 TL 72 700 Td (First row) Tj T* (Second row) Tj ET", "Second", "Sőcond"), + ] { + let mut doc = embedded_program_page(content, "Inter-Regular"); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let draft = block.editor_text.replacen(from, to, 1); + let edit = text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle::default()).unwrap(); + assert_eq!(edit.substituted, None, "{to}: no other family stands in"); + let doc = reopen(&doc); + // The new character is its own run in the other resource, read back into the same word. + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft, "{to}"); + let fonts = page_font_programs(&doc); + assert!(fonts.iter().all(|(subtype, _)| subtype != b"Type3"), "{to}: no fallback glyphs"); + assert!( + fonts.iter().any(|(subtype, program)| subtype == b"Type0" && program.as_deref() == Some(inter.as_slice())), + "{to}: the source program draws it" + ); + } +} + +#[test] +fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() { + let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); + let before = page_content_bytes(&doc, 0); + let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()); + if pdfcraft_fonts::document_japanese_font().is_some() { + eprintln!("built with craft-fonts: the fallback font draws the new character"); + assert!(result.is_ok(), "{result:?}"); + return; + } + let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") }; + assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); + assert_eq!(page_content_bytes(&doc, 0), before); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 13233f71d4..d434d01a95 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1166,6 +1166,22 @@ fn unicode_hex(ch: char) -> String { encoded.iter().map(|u| format!("{u:04X}")).collect() } +/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other +/// characters are missing from the paragraph's font, and choosing a font that has them works too. +fn no_fallback_font(text: &str) -> EditError { + let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF); + if text.chars().any(japanese) { + return no_japanese_font(); + } + let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; + EditError::Invalid(format!( + "the paragraph's font has no glyph for {ch} (U+{:04X}) and this build has no fallback font for it; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + u32::from(ch) + )) +} + /// This build has no Japanese face to draw replacement text with. fn no_japanese_font() -> EditError { EditError::Invalid( @@ -1230,7 +1246,7 @@ fn type3_font( // character. When none has them all, the best face reports the character it lacks. Hebrew // text keeps the Hebrew face and takes each character it lacks from the next face that has it. let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) } - .ok_or_else(no_japanese_font)?; + .ok_or_else(|| no_fallback_font(text))?; let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] }; // Every outline is read before the document is touched, so a character that fails leaves it // as it was. Each font takes the next `per_font` characters; codes restart at 1 in each. @@ -1338,6 +1354,28 @@ fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option Option> { + let font = fonts.get(name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())?; + let base = Metrics::from_dict(doc, &font).base_font; + if base.split_once('+').is_some_and(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())) { + return None; + } + let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)?; + pdfcraft_fonts::embedding::validate(&bytes).is_ok().then_some(bytes) +} + +/// Whether `text` has characters `line`'s font can't encode that its own program can draw. +fn source_program_completes(doc: &Document, fonts: &Dict, line: &TextLine, text: &str) -> bool { + let Some(metrics) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|f| Metrics::from_dict(doc, &f)) else { + return false; + }; + let missing: String = text.chars().filter(|c| !c.is_whitespace() && !source_encodes(&metrics, &c.to_string())).collect(); + !missing.is_empty() && source_program(doc, fonts, &line.font).is_some_and(|bytes| pdfcraft_fonts::embedding::covers(&bytes, &missing)) +} + /// A subset may contain all the letters but no space glyph: producers use another subset /// or a positioning operator for spaces. Do the same instead of substituting the whole font. fn source_encodes(metrics: &Metrics, text: &str) -> bool { @@ -1722,6 +1760,7 @@ pub(crate) fn rewrite_block_with_limit( || pdfcraft_fonts::source_shaping::required(draft) || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() }) || registered_face + || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) || b.rows.iter().any(|row| row.len() > 1) || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) && style.family.is_none() diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 6143496815..59e1607021 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -384,6 +384,7 @@ pub(super) fn rewrite_styled_block( let mut substituted = None; let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); let mut full_faces: HashMap = HashMap::new(); + let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { let source = lines.get(index).unwrap_or(first); for ch in draft.get(range).unwrap_or_default().chars() { @@ -419,6 +420,28 @@ pub(super) fn rewrite_styled_block( full_faces.insert(key, name.clone()); name } + } else if let Some(bytes) = programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) + { + // The document's own complete program of this font has the glyph: draw it with + // that face rather than another family's fallback. + let key = format!("source {}", source.font); + if let Some(name) = full_faces.get(&key) { + name.clone() + } else { + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + full_faces.insert(key, name.clone()); + name + } } else { let family = source_family(&source.base_font); if needs_type3(&s) { diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 07104d79e0..67a3a4f870 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -15,7 +15,10 @@ reconstructed from a glyph the PDF does not contain. **Load font…** imports an embeddable standalone `.ttf` or `.otf` face. **Installed fonts…** discovers eligible user-installed faces on request, off the UI thread; the document-font menu selects resources already on the page. Select **Keep source fonts** to preserve each run's original face. A matching full face -completes its subsets automatically, including new Chinese characters. Choosing another face +completes its subsets automatically, including new Chinese characters. When the PDF itself embeds +the complete program of a paragraph's font (not a subset) and its licence allows editable +embedding, a character the font's encoding can't show is drawn with that same program. A +character no available face has is refused with an error that names it. Choosing another face is explicit. Fonts with restricted embedding, unsupported collections, excluded families or no open-licence declaration are refused. Interface fallback fonts are never exported. From 58e125ccf1acf8618109cd456c5d73b875b913ae Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:23:58 +0200 Subject: [PATCH 041/207] Automation: tell a tool name or an unknown id from a command without a tool in command_run (fixes #1759) (#1765) --- crates/automation/src/conventions.rs | 18 +++++++++++++++++- crates/automation/tests/automation.rs | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs index af8cf671ac..ac670109fb 100644 --- a/crates/automation/src/conventions.rs +++ b/crates/automation/src/conventions.rs @@ -2,15 +2,31 @@ use serde_json::{Value, json}; use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools}; +use pdfcraft_engine::commands; use pdfcraft_render::{RenderRequest, RequestKind}; /// The most steps one `command_batch` runs. pub(crate) const MAX_BATCH_STEPS: usize = 1000; +/// Why `id` has no tool to run: a tool name passed as a command id, an id the registry doesn't +/// know, or a real command that nothing automates yet. They need different fixes from the caller. +fn no_tool_for(id: &str) -> ToolError { + if commands::command(id).is_some() { + return failed(format!("command {id:?} has no headless tool; see command_list")); + } + match tools::find(id) { + Some(def) => match def.command { + Some(command) => failed(format!("{id:?} is a tool, not a command id; use the command id {command:?}, or call the tool directly")), + None => failed(format!("{id:?} is a tool, not a command id; call it directly (command ids are in command_list)")), + }, + None => failed(format!("unknown command id {id:?}; see command_list")), + } +} + impl Automation { pub(crate) fn command_run(&mut self, args: &Args) -> Result> { let id = args.str("id")?; - let tool = tools::tool_for_command(id).ok_or_else(|| failed(format!("command {id:?} has no headless tool; see command_list")))?; + let tool = tools::tool_for_command(id).ok_or_else(|| no_tool_for(id))?; let def = tools::find(tool).ok_or_else(|| failed("command tool is unavailable"))?; let mut params = match args.get("params") { None | Some(Value::Null) => serde_json::Map::new(), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index d44c7ab5b4..a6c6fad46b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5179,6 +5179,24 @@ fn command_batch_refuses_more_than_a_thousand_steps() { assert!(err.to_string().contains("at most 1000 steps"), "{err}"); } +#[test] +fn command_batch_tells_a_tool_name_from_an_unknown_or_toolless_command() { + let dir = workdir("batch-ids"); + let mut a = auto(&dir); + let steps = json!([{"id":"page_rotate"}, {"id":"doc_inspect"}, {"id":"no.such.command"}, {"id":"help.about"}]); + let r = ok(&mut a, "command_batch", json!({"steps": steps, "stop_on_error": false})); + let errors: Vec<&str> = r["results"].as_array().unwrap().iter().map(|s| s["error"].as_str().unwrap()).collect(); + assert_eq!( + errors, + [ + r#""page_rotate" is a tool, not a command id; use the command id "page.rotate", or call the tool directly"#, + r#""doc_inspect" is a tool, not a command id; call it directly (command ids are in command_list)"#, + r#"unknown command id "no.such.command"; see command_list"#, + r#"command "help.about" has no headless tool; see command_list"#, + ] + ); +} + #[test] fn thai_text_through_tools_saves_reopens_extracts_and_renders() { let dir = workdir("thai-content"); From 5098728078134cda1b71605ba6f0587d004846d6 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:24:09 +0500 Subject: [PATCH 042/207] M7: reuse a page's font resource and program when embedding again (#1779) Every call to `embed` or `embed_mapping` wrote a new Type 0 font, CIDFont, descriptor, ToUnicode and glyph map, under a new `PCFull{n}` name. Editing a paragraph that needs a full face (a completed subset, shaped Arabic, a loaded font) therefore added another set of font objects to the page on every apply, even when the text was the same. The program itself was only shared when it was among the first 8192 objects of the document, so on larger files each edit also copied the whole font program, often several megabytes. `embed_mapping` now looks at the page's own `PCFull` resources first: - one with the same font name, program, glyph map (`CIDToGIDMap` or the CFF encoding CMap) and ToUnicode map is returned as it is, and nothing is written; - otherwise a program one of them already uses is shared, wherever it sits in the document, before the document-wide search. Programs are compared by the length they were written with before their data is decoded, and streams are compared decoded, so a file another writer compressed since is still recognised. Pruning resources a rewrite no longer uses is left for later: a page's /Resources can be shared with other pages, so it needs a check of every content stream that uses it. The test embeds after 9000 other objects, so the program lies beyond the document-wide search. Embedding the same text twice returns the same resource; two mappings share one program; after the streams are compressed, embedding again writes no objects; a different program under the same name gets its own resource. It fails without this change ("PCFull1" against "PCFull0"). Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/embedding.rs | 113 ++++++++++++++++++++++++++++++++-- docs/editing-existing-text.md | 4 +- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index fbd361e8bf..84801d5fd2 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -172,6 +172,10 @@ pub fn embed_mapping( if name.is_empty() || name.len() > 128 { return Err("the font family name is invalid".into()); } + let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() }; + if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) { + return Ok(resource); + } let mut descriptor = Dict::new(); descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); descriptor.set(b"FontName".to_vec(), Object::name(&name)); @@ -187,10 +191,13 @@ pub fn embed_mapping( b"FontBBox".to_vec(), Object::Array([bounds.x_min, bounds.y_min, bounds.x_max, bounds.y_max].map(|v| Object::Real(f64::from(v) * scale)).to_vec()), ); - // Reuse a full program registered on another page; source subset programs stay untouched. - let existing = doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), - _ => None, + // Reuse the program another resource of this page already has, else one registered on another + // page; source subset programs stay untouched. + let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { + doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { + Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), + _ => None, + }) }); let program = existing.unwrap_or_else(|| { let mut d = Dict::new(); @@ -239,6 +246,58 @@ pub fn embed_mapping( Ok(resource) } +/// The decoded data of the stream `object` refers to. +fn stream_data(doc: &Document, object: Option<&Object>) -> Option> { + match doc.resolve(object?).as_ref() { + Object::Stream(s) => s.decoded_within(17 << 20).ok(), + _ => None, + } +} + +/// A `PCFull` resource that [`embed_mapping`] wrote. +struct Written { + resource: String, + top: Dict, + cid: Dict, + /// The reference to its full program. + program: Object, +} + +impl Written { + /// Whether its program is `bytes`. The length it was written with is compared before decoding. + fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { + let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false }; + s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() + && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) + } +} + +/// The `PCFull` resources in `fonts`, in order. +fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { + fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { + let top = doc.dict(value)?; + let cid = doc.dict(doc.resolve(top.get(b"DescendantFonts")?).as_array()?.first()?)?; + let descriptor = doc.dict(cid.get(b"FontDescriptor")?)?; + let program = descriptor.get(b"FontFile2").or_else(|| descriptor.get(b"FontFile3")).filter(|p| matches!(p, Object::Ref(_)))?.clone(); + Some(Written { resource: String::from_utf8_lossy(key).into_owned(), top, cid, program }) + }) +} + +/// A resource in `fonts` that already is this embedding: the same font name, program, glyph map +/// and Unicode map. Editing the same text again reuses it instead of adding another copy. +fn same_resource(doc: &Document, fonts: &Dict, name: &str, bytes: &[u8], glyph_map: &[u8], unicode: &[u8]) -> Option { + written(doc, fonts) + .find(|w| { + let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding")); + w.top.name(b"BaseFont") == Some(name.as_bytes()) + && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode) + && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map) + && w.has_program(doc, bytes) + }) + .map(|w| w.resource) +} + #[cfg(test)] mod tests { use super::*; @@ -274,4 +333,50 @@ mod tests { assert_eq!(doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(), 1); assert_eq!(face_key("ABCDEF+Droid-Sans-Fallback"), face_key("Droid Sans Fallback")); } + + #[test] + fn embedding_the_same_text_again_reuses_its_resource_and_program() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = FontRef::new(bytes).unwrap(); + let glyph = |c| face.charmap().map(c).unwrap(); + let programs = |doc: &Document| { + doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count() + }; + // Objects before the program push it past the first 8192 that the document-wide search reads. + let mut doc = Document::new_empty(); + for _ in 0..9000 { + doc.add(Object::Null); + } + let mut fonts = Dict::new(); + let full = embed(&mut doc, &mut fonts, bytes, "Inter").unwrap(); + let objects = doc.scan_objects().count(); + assert_eq!(embed(&mut doc, &mut fonts, bytes, "Inter").unwrap(), full, "the same embedding is the same resource"); + let shaped = [("fi".to_string(), glyph('f')), ("é".to_string(), glyph('é'))]; + let first = embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped).unwrap(); + assert_ne!(first, full, "another mapping is another resource"); + assert_eq!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped).unwrap(), first); + assert_eq!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped[..1]).map(|r| r != first), Ok(true), "a different mapping"); + assert_eq!(programs(&doc), 1, "one program for every mapping"); + assert_eq!(fonts.iter().count(), 3); + assert!(doc.scan_objects().count() > objects); + // Another writer may have compressed the streams since; the page still recognises them. + for (r, o) in doc.scan_objects().collect::>() { + if let Object::Stream(st) = o.as_ref() + && st.dict.get(b"Filter").is_none() + { + let data = st.decoded().unwrap(); + doc.set(r, Object::Stream(Stream::flate(st.dict.clone(), &data))); + } + } + let objects = doc.scan_objects().count(); + assert_eq!(embed(&mut doc, &mut fonts, bytes, "Inter").unwrap(), full); + assert_eq!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped).unwrap(), first); + assert_eq!(doc.scan_objects().count(), objects, "nothing new is written"); + // A different program under the same name is not taken for this one. + let other = include_bytes!("../../../assets/fonts/Inter-Medium.ttf"); + let mut more = fonts.clone(); + let medium = embed(&mut doc, &mut more, other, "Inter").unwrap(); + assert!(!fonts.contains(medium.as_bytes()), "{medium} is new"); + assert_eq!(programs(&doc), 2); + } } diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 67a3a4f870..9708cf17ed 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -23,7 +23,9 @@ is explicit. Fonts with restricted embedding, unsupported collections, excluded no open-licence declaration are refused. Interface fallback fonts are never exported. The full selected program, glyph mappings, widths and Unicode text are embedded in the PDF. -Saving and reopening therefore needs no locally installed font. Original subset bytes are +Saving and reopening therefore needs no locally installed font. Editing the same text again +reuses the font resource the page already has, and every resource made from one program +shares a single copy of it. Original subset bytes are preserved. Optional matching faces from `CRAFT_FONTS_DIR` can also complete source subsets; this works only when that build input actually contains the full face. This change does not add a font binary or change the release font pin. Fonts shared by releases belong in From c5c5d0d661b23c490192e8be4b119ade5bea543e Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 08:24:33 +0200 Subject: [PATCH 043/207] M2: Add word and character counts with optional content exclusions (#1780) * M2: Add word and character counts with optional content exclusions Retain the complete reviewed change, including prior integration resolutions, on current upstream. M2: Add document word counts and semantic exclusions * Refresh catalog hashes after merging main --------- Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 42 ++-- Cargo.lock | 1 + crates/automation/README.md | 4 + crates/automation/src/lib.rs | 23 ++ crates/automation/src/tools.rs | 6 + crates/automation/tests/automation.rs | 24 ++ crates/engine/Cargo.toml | 1 + crates/engine/README.md | 3 + crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/word_count.rs | 336 ++++++++++++++++++++++++++ crates/render/src/raster.rs | 8 + crates/render/src/text.rs | 71 +++++- crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 6 + crates/ui-egui/src/i18n/ar.tsv | 16 ++ crates/ui-egui/src/i18n/bg.tsv | 16 ++ crates/ui-egui/src/i18n/cs.tsv | 16 ++ crates/ui-egui/src/i18n/de.tsv | 16 ++ crates/ui-egui/src/i18n/el.tsv | 16 ++ crates/ui-egui/src/i18n/es.tsv | 16 ++ crates/ui-egui/src/i18n/fr.tsv | 16 ++ crates/ui-egui/src/i18n/hu.tsv | 16 ++ crates/ui-egui/src/i18n/it.tsv | 16 ++ crates/ui-egui/src/i18n/ja.tsv | 16 ++ crates/ui-egui/src/i18n/kk.tsv | 16 ++ crates/ui-egui/src/i18n/lt.tsv | 16 ++ crates/ui-egui/src/i18n/pl.tsv | 16 ++ crates/ui-egui/src/i18n/pt-br.tsv | 16 ++ crates/ui-egui/src/i18n/ru.tsv | 16 ++ crates/ui-egui/src/i18n/sl.tsv | 16 ++ crates/ui-egui/src/i18n/te.tsv | 16 ++ crates/ui-egui/src/i18n/tr.tsv | 16 ++ crates/ui-egui/src/i18n/uk.tsv | 16 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 16 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 16 ++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/word_count_ui.rs | 225 +++++++++++++++++ docs/word-count.md | 52 ++++ parity/acrobat-features.toml | 12 + 40 files changed, 1130 insertions(+), 28 deletions(-) create mode 100644 crates/engine/src/word_count.rs create mode 100644 crates/ui-egui/src/word_count_ui.rs create mode 100644 docs/word-count.md diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 763dc4a212..4c7103baf5 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "1fd7a303d0c256893a3201eee403386f1501a5706af5903e843e9f5a8449d619" +sha256 = "a579e60a58e88ea83732609f2fb0921afa3d5d61082bea3be969db7ad6d3a12f" # 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 = "e3e896ca45a954406a235b66085de6a3e6c8209a1d2a1164ac8ae88a1562cdfb" +sha256 = "eae078763f4d4b6267cce596d405e28102ebb872d9cb8bc375671cd4d264da9e" [[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 = "8ec618949e296e540df72051ff284e5d082f3cd999202f727660f91a7dd3c7b9" +sha256 = "b94db0634220f21673b649be78e65bf5d9b2f939afd3992b1a5ab567d77677b0" [[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 = "ffb55f4e78a7dacb6d76bc36f1f5e593b889d5f6680f1c53c8818b1b9cdce501" +sha256 = "6f2d46364ee4a703f6a05017d5cf6f2da4216bb83b577092b456db742a961d2a" [[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 = "714af0e8635275baec6f8a07dd96fc1976dfa5269d462549446985a8786bbb21" +sha256 = "0a44a27428691465074177508dde58475188fc3ad176d5f43a74dd4d518a2495" [[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 = "fbdf46be923e9cc88cb62019108239d2a47fdd3cc54e87f30a70ad9497364180" +sha256 = "4320582e8212a8387bce630107e19ef2ffb78586d453d36872ac40a9bf917b1b" [[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 = "e0f031f7ec4c30e9750575792ae18920a0a42ad215f1c394632b52ca308ca65f" +sha256 = "0060ce0ff2ee45ac5d6ebfcc39eb9aec565006fee2867623e12572a1b4b80b5e" [[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 = "4a3bbef4db399b02ec62804e4614a2dec42942f90da613854590cbc4b06ea082" +sha256 = "abeb8b6758e460d9d1fac894392f280fc2d0dadb30edce784ee0625eb318726c" [[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 = "41ab9886af95cc845ff8d7d89f2794e3663f5682c21e9f553480edb5499f5abf" +sha256 = "7fc8def1baa4680ac1821928c4b33c27ad15257b6be8ecaa43d6bad28d49d2b3" [[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 = "1ecc78878e5c5151f1afc4be2cb15ba05095d35119a2bfadac02b0b921c52df4" +sha256 = "ef898359cd992adff06d1889536aea90246ae63443f5181e71a8b8b12cdfd4d8" [[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 = "1e30d02a94f554abf24f23e84242bcc04688de5e3631873147504f900568d13a" +sha256 = "2ee8d548ad696820a9710937d4d27d5dfe529244cd20df2664a4192ded639408" [[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 = "ae04d6fab0f225d01de7907d70dc58a9135d65f127f845c553344bcfd08fcd6d" +sha256 = "e7f2727e4ee17a2f9a37bf8d8bf563c625c8700e1389de0b79c22002b8d3919c" [[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 = "7ff4cd557d7d3ee1ada2d667653db578bae8964f021366cb11d9d03f81d8cb32" +sha256 = "b1bb541fa2a453537c383b9346d8c9504b7559004ae94a851f18348de2910694" [[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 = "fefc14be320cc7625b1c0a02cc26f27722b5d73052ef2d6056f3bb07363e5888" +sha256 = "f90f24f1d30c8e8cdc595e2b229e1aca35125b55af0fce5b26d0befbb31f1fc1" [[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 = "6590241c4156d0da89011948e5fd65b363916d7724390d854d19cd21d3e9f440" +sha256 = "5bc5f87e61cc1feb64d9ae94ec39f218aa7abe6697065647152104197a356d48" [[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 = "12f0cfe67ad61741ebb96668137eb62a520ef3c4bb7961d451911c73d58ae0b8" +sha256 = "95d004849c3760587098ea0bfc658aa0d94887ff4f2a22d7f3d1565df5ddaf30" [[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 = "5db1450bf0e71db21c9bcb994b4e7a529106ae52ffae2d6118997295e0ac8517" +sha256 = "ca109e277cbe196394fb50323c632ff65972f9aa60941e69cbb244857930eec0" [[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 = "b34a98f97fb5ceb8f602ab005e859b070d72d73fd3def5a73a3f9eb6915a4d28" +sha256 = "aa1b6741974392a8b246bab04169608211d6d6f19f2adb000cf033a6d1a3ef2e" [[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 = "17b984f557eb36908baf06880a3b540eadc39910283b37e0ae905175db320f45" +sha256 = "895f2f8e6b44f7cf7f383e13765e62b5875938ded5135119b3e0621f6375c3d5" [[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 = "2daa34aacddccdbe9862932c5c6f0a0d8bdb86b07cc385a82f0ceaf9f1c4f333" +sha256 = "e5f36292f84ec43ae42479735f88c2667bef05f719ab11e47daaa517cd070f3e" [[asset]] @@ -3363,4 +3363,4 @@ 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 = "25f947c54e32c9bc7fb0411ade704fc464fbee7f8a73155f4cf23516311547ce" +sha256 = "25523c021600c5aecc778f53e707a114267a4463ab911fd83658a4c241ece6d3" diff --git a/Cargo.lock b/Cargo.lock index 3a63eb57d6..d7dc06ab5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4067,6 +4067,7 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "unicode-segmentation", "url", ] diff --git a/crates/automation/README.md b/crates/automation/README.md index d702b7ed4f..3d3a46c0fa 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -30,6 +30,10 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS ## Text search and cache limits +`text_word_count` counts the current snapshot without filling the text cache. It returns totals +and per-page Unicode word/character counts, with optional tagged-table, tagged-code and +image/OCR exclusions. See [word count](../../docs/word-count.md) for counting rules and limits. + Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 247da4a55f..2856994733 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -186,6 +186,7 @@ impl Automation { "page_render" => return self.page_render(&a).map(|c| vec![c]), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, "text_find" => self.text_find(&a)?, "page_rotate" => { let degrees = a.int("degrees")?; @@ -1758,6 +1759,28 @@ impl Automation { Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) } + fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + fn text_extract(&mut self, a: &Args) -> Result { let doc = self.doc_to_copy(a)?; let id = doc.id; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 98bf2754af..4e684c7215 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -171,6 +171,12 @@ pub fn tools() -> Vec { "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, "offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}, "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])), + t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.") + .ro() + .cmd("file.word_count") + .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), + "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } + }), &["doc"])), t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.") .ro() .cmd("edit.column_select") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a6c6fad46b..38d67f6d68 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -433,6 +433,29 @@ fn open_inspect_render_and_find() { assert_eq!((info.width, info.height), (200, 300)); } +#[test] +fn word_count_reads_selected_pages_and_unsaved_edits_without_mutating() { + let dir = workdir("word-count"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "doc_list", json!({})); + let report = ok(&mut a, "text_word_count", json!({ "doc": doc })); + assert_eq!(report["total"]["words"], 6); + assert_eq!(report["pages"].as_array().unwrap().len(), 3); + let subset = ok(&mut a, "text_word_count", json!({ "doc": doc, "pages": [3, 1], "exclude_tables": true })); + assert_eq!(subset["total"]["words"], 4); + assert_eq!(subset["pages"][0]["page"], 3); + assert_eq!(subset["unclassified_structure"], true); + assert_eq!(ok(&mut a, "doc_list", json!({})), before); + assert!(a.call("text_word_count", &json!({ "doc": doc, "pages": [4] })).is_err()); + ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [1] })); + assert_eq!(ok(&mut a, "text_word_count", json!({ "doc": doc }))["total"]["words"], 4); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "text_word_count", json!({ "doc": doc }))["total"]["words"], 6); + let def = tools().into_iter().find(|t| t.name == "text_word_count").unwrap(); + assert!(def.read_only && !def.destructive); +} + #[test] fn text_editor_background_is_read_only_and_removes_source_glyphs() { let dir = workdir("text-preview"); @@ -1599,6 +1622,7 @@ fn protecting_through_tools() { // Copying wasn't allowed either (#568): no text or images come out, though search works. for (tool, args) in [ ("text_extract", json!({ "doc": re })), + ("text_word_count", json!({ "doc": re })), ("text_paragraphs", json!({ "doc": re, "page": 1 })), ("doc_export_text", json!({ "doc": re, "path": "out.txt" })), ("doc_export_images", json!({ "doc": re, "folder": "pages" })), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eecbb35c4b..7de0287634 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -41,6 +41,7 @@ png = "0.18" url = "2.5" idna = "1.1" icu_properties = "2.3" +unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Already in the graph through lopdf; used only for the local UTC offset ("today"). diff --git a/crates/engine/README.md b/crates/engine/README.md index 982d2fb1a1..4bf80e3f73 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -3,6 +3,9 @@ The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates. +`word_count::count` provides cancellable, page-at-a-time Unicode word and character statistics +with optional semantic exclusions; see [word-count behavior and limits](../../docs/word-count.md). + - **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** — `egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it. - The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cf40386451..228bdd0ffe 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -190,6 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"), c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"), c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"), + c("file.word_count", "Word count…", FILE, None, Copying, "hash"), ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3b9a1e032..e1654e781f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod signature_scan; pub mod text_fonts; mod text_preview; mod upright_image; +pub mod word_count; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; diff --git a/crates/engine/src/word_count.rs b/crates/engine/src/word_count.rs new file mode 100644 index 0000000000..bd31c27e4c --- /dev/null +++ b/crates/engine/src/word_count.rs @@ -0,0 +1,336 @@ +//! Read-only document statistics, processing one page at a time without rasterizing it. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, ObjRef, Object}; +use pdfcraft_render::{PageRenderer, text::TextFilter}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::export::ExportSource; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Options { + pub exclude_tables: bool, + pub exclude_code: bool, + /// Figure-marked text and invisible text (including OCR). This does not run OCR. + pub exclude_images: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Counts { + pub words: usize, + /// Unicode scalar values, including whitespace. + pub characters: usize, + pub characters_without_spaces: usize, +} + +impl Counts { + pub fn of(text: &str) -> Self { + Self { + words: text.unicode_words().count(), + characters: text.chars().count(), + characters_without_spaces: text.chars().filter(|c| !c.is_whitespace()).count(), + } + } + + fn add(&mut self, other: Self) { + self.words = self.words.saturating_add(other.words); + self.characters = self.characters.saturating_add(other.characters); + self.characters_without_spaces = self.characters_without_spaces.saturating_add(other.characters_without_spaces); + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Report { + pub total: Counts, + /// Zero-based pages, in the requested order, without duplicates. + pub pages: Vec<(usize, Counts)>, + pub pages_without_text: usize, + /// Some structure could not be classified safely; that text was retained. + pub unclassified_structure: bool, +} + +/// Count the current document snapshot. `progress` can cancel between pages. A failed page is +/// an error, never a successful zero or an unlabelled partial total. +pub fn count(source: ExportSource, pages: &[usize], options: Options, progress: impl FnMut(usize) -> bool) -> Result { + crate::guard(|| count_inner(source, pages, options, progress))? +} + +fn count_inner(source: ExportSource, pages: &[usize], options: Options, mut progress: impl FnMut(usize) -> bool) -> Result { + if pages.iter().any(|&page| page >= source.pages) { + return Err("the word-count page range is out of bounds".into()); + } + if !progress(0) { + return Err("word count cancelled".into()); + } + let (mut excluded, unclassified) = if options != Options::default() { exclusions(&source, options)? } else { (HashMap::new(), false) }; + let mut renderer = PageRenderer::new(source.bytes, source.config); + let mut report = Report { unclassified_structure: unclassified, ..Report::default() }; + let mut seen = HashSet::new(); + for &page in pages { + if !seen.insert(page) { + continue; + } + if !progress(report.pages.len()) { + return Err("word count cancelled".into()); + } + let filter = TextFilter { + tables: options.exclude_tables, + code: options.exclude_code, + images: options.exclude_images, + mcids: excluded.remove(&page).unwrap_or_default(), + }; + let text = renderer.filtered_text(page, filter).map_err(|e| format!("page {}: {e}", page.saturating_add(1)))?.plain_text(); + let counts = Counts::of(&text); + report.pages_without_text += usize::from(text.trim().is_empty()); + report.total.add(counts); + report.pages.push((page, counts)); + } + if !progress(report.pages.len()) { + return Err("word count cancelled".into()); + } + Ok(report) +} + +fn excluded_role(role: &[u8], options: Options) -> bool { + (options.exclude_tables && matches!(role, b"Table" | b"TR" | b"TD" | b"TH" | b"THead" | b"TBody" | b"TFoot")) + || (options.exclude_code && role == b"Code") + || (options.exclude_images && role == b"Figure") +} + +fn standard_role(doc: &pdfcraft_cos::Document, map: &Dict, role: &[u8]) -> Vec { + let mut role = role.to_vec(); + for _ in 0..32 { + let Some(next) = map.get(&role).map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(<[u8]>::to_vec)) else { break }; + if next == role { + break; + } + role = next; + } + role +} + +type Exclusions = HashMap>; + +/// Resolve page-local MCIDs through the structure hierarchy, including inherited roles and +/// RoleMap aliases. Form XObjects have a separate MCID namespace that the text device does not +/// expose: retain their page's MCID-tagged text instead of excluding an unrelated same-numbered +/// span. Explicit Table/Code/Figure marked-content tags still work on those pages. +fn exclusions(source: &ExportSource, options: Options) -> Result<(Exclusions, bool), String> { + let doc = pdfcraft_cos::Document::open_with_password(source.bytes.clone(), source.config.password.as_deref()).map_err(|e| e.to_string())?; + let catalog = doc.root().and_then(|id| doc.get(id).as_dict().cloned()).unwrap_or_default(); + let Some(root) = catalog.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else { + return Ok((HashMap::new(), options.exclude_tables || options.exclude_code)); + }; + let roles = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let pages = pdfcraft_model::pages(&doc); + let page_index: HashMap<_, _> = pages.iter().enumerate().map(|(index, page)| (page.obj, index)).collect(); + let mut stack = root.get(b"K").map(|k| vec![(k.clone(), None::, false, 0usize)]).unwrap_or_default(); + let mut seen = HashSet::new(); + let mut out: Exclusions = HashMap::new(); + let mut unclassified = false; + let mut visited = 0usize; + while let Some((object, page, excluded, depth)) = stack.pop() { + visited += 1; + if visited > 100_000 || depth > 128 { + unclassified = true; + break; + } + if let Some(id) = object.as_ref() + && !seen.insert(id) + { + unclassified = true; + continue; + } + let object = doc.resolve(&object); + let mut keep = |id: i64, page: Option| { + if excluded { + if let Some((page, id)) = page.and_then(|p| page_index.get(&p).copied()).zip(i32::try_from(id).ok().filter(|id| *id >= 0)) { + out.entry(page).or_default().insert(id); + } else { + unclassified = true; + } + } + }; + match &*object { + Object::Int(id) => keep(*id, page), + Object::Array(items) => { + if stack.len().saturating_add(items.len()) > 100_000 { + unclassified = true; + break; + } + stack.extend(items.iter().rev().map(|child| (child.clone(), page, excluded, depth + 1))); + } + Object::Dict(dict) => { + let page = dict.get(b"Pg").and_then(Object::as_ref).or(page); + if dict.contains(b"Stm") { + unclassified = true; + } else if let Some(id) = dict.get(b"MCID").and_then(Object::as_int) { + keep(id, page); + } else if let Some(kids) = dict.get(b"K") { + let excluded = excluded || dict.name(b"S").is_some_and(|role| excluded_role(&standard_role(&doc, &roles, role), options)); + stack.push((kids.clone(), page, excluded, depth + 1)); + } + } + _ => {} + } + } + for (index, page) in pages.iter().enumerate() { + if !out.contains_key(&index) { + continue; + } + let resources = page.dict.get(b"Resources").map(|o| doc.resolve(o)); + let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o)); + let has_form = objects.as_ref().and_then(|o| o.as_dict()).is_some_and(|d| { + d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))) + }); + if has_form { + out.remove(&index); + unclassified = true; + } + } + Ok((out, unclassified)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source(bodies: &[&str], structure: &str) -> ExportSource { + let kids = (0..bodies.len()).map(|i| format!("{} 0 R", 4 + i * 2)).collect::>().join(" "); + let mut objects = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {structure} >>"), + format!("<< /Type /Pages /Kids [{kids}] /Count {} /MediaBox [0 0 200 300] >>", bodies.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + for (i, body) in bodies.iter().enumerate() { + objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + i * 2)); + objects.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.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(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + ExportSource { bytes: std::sync::Arc::new(bytes), config: Default::default(), pages: bodies.len(), sizes: vec![(200.0, 300.0); bodies.len()] } + } + + const MARKED: &str = "BT /F1 12 Tf 10 270 Td (Body words) Tj ET + /Table BMC BT /F1 12 Tf 10 240 Td (Table words) Tj ET EMC + /Code BMC BT /F1 12 Tf 10 210 Td (Code words) Tj ET EMC + /Figure BMC BT /F1 12 Tf 10 180 Td (Figure words) Tj ET EMC + BT /F1 12 Tf 3 Tr 10 150 Td (OCR words) Tj ET"; + + #[test] + fn word_count_filters_are_independent_and_do_not_change_normal_extraction() { + let src = source(&[MARKED], ""); + for (options, expected) in [ + (Options::default(), 10), + (Options { exclude_tables: true, ..Options::default() }, 8), + (Options { exclude_code: true, ..Options::default() }, 8), + (Options { exclude_images: true, ..Options::default() }, 6), + (Options { exclude_tables: true, exclude_code: true, exclude_images: true }, 2), + ] { + assert_eq!(count(src.clone(), &[0], options, |_| true).unwrap().total.words, expected); + } + let mut renderer = PageRenderer::new(src.bytes, src.config); + assert_eq!(Counts::of(&renderer.filtered_text(0, TextFilter::default()).unwrap().plain_text()).words, 10); + assert_eq!(renderer.stats().rasterizations, 0); + } + + #[test] + fn word_count_resolves_structure_roles_and_page_local_mcids() { + let body = "BT /F1 12 Tf 10 260 Td (Body words) Tj ET /Span << /MCID 0 >> BDC BT /F1 12 Tf 10 220 Td (Tagged words) Tj ET EMC"; + let src = source( + &[body, body], + "/StructTreeRoot << /RoleMap << /Grid /Table >> /K [<< /S /Grid /Pg 4 0 R /K << /S /Span /K 0 >> >> << /S /Code /Pg 6 0 R /K << /Type /MCR /MCID 0 >> >>] >>", + ); + let tables = count(src.clone(), &[0, 1], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap(); + assert_eq!(tables.pages.iter().map(|(_, c)| c.words).collect::>(), vec![2, 4]); + assert!(!tables.unclassified_structure); + let code = count(src, &[1, 0], Options { exclude_code: true, ..Options::default() }, |_| true).unwrap(); + assert_eq!(code.pages.iter().map(|(_, c)| c.words).collect::>(), vec![2, 4]); + } + + #[test] + fn word_count_handles_empty_duplicate_invalid_and_cancelled_ranges() { + let src = source(&["BT /F1 12 Tf 10 220 Td (Two words) Tj ET", ""], ""); + let report = count(src.clone(), &[1, 0, 1], Options::default(), |_| true).unwrap(); + assert_eq!(report.total.words, 2); + assert_eq!(report.pages.len(), 2); + assert_eq!(report.pages_without_text, 1); + assert_eq!(count(src.clone(), &[], Options::default(), |_| true).unwrap().total, Counts::default()); + assert!(count(src.clone(), &[2], Options::default(), |_| true).unwrap_err().contains("bounds")); + assert!(count(src.clone(), &[0, 1], Options::default(), |done| done < 1).unwrap_err().contains("cancelled")); + let invalid = ExportSource { bytes: std::sync::Arc::new(b"not a PDF".to_vec()), ..src }; + assert!(count(invalid, &[0], Options::default(), |_| true).is_err()); + } + + #[test] + fn word_count_actual_text_cannot_reintroduce_excluded_words() { + let all = source(&["/Span << /ActualText (OCR words) >> BDC BT /F1 12 Tf 3 Tr 10 220 Td (xx) Tj ET EMC"], ""); + let images = Options { exclude_images: true, ..Options::default() }; + assert_eq!(count(all.clone(), &[0], Options::default(), |_| true).unwrap().total.words, 2); + assert_eq!(count(all, &[0], images, |_| true).unwrap().total.words, 0); + let mixed = source( + &[ + "/Span << /ActualText (Body and code) >> BDC BT /F1 12 Tf 10 220 Td (body) Tj ET /Code BMC BT /F1 12 Tf 50 220 Td (code) Tj ET EMC EMC", + ], + "", + ); + assert_eq!(count(mixed.clone(), &[0], Options::default(), |_| true).unwrap().total.words, 3); + assert!(count(mixed, &[0], Options { exclude_code: true, ..Options::default() }, |_| true).unwrap_err().contains("replacement text")); + } + + #[test] + fn word_count_cyclic_structure_is_bounded_and_reported() { + // The structure reaches the catalog again instead of a structure child. + let src = source(&[MARKED], "/StructTreeRoot << /K << /S /Table /Pg 4 0 R /K [0 1 0 R] >> >> /K 1 0 R"); + let report = count(src, &[0], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap(); + assert!(report.unclassified_structure); + } + + #[test] + fn word_count_retains_form_namespaces_and_reports_the_limitation() { + let body = "/Span << /MCID 0 >> BDC BT /F1 12 Tf 10 220 Td (Page words) Tj ET EMC /Fm Do"; + let mut src = source(&[body], "/StructTreeRoot << /K << /S /Table /Pg 4 0 R /K 0 >> >>"); + let mut doc = pdfcraft_cos::Document::open(src.bytes.clone()).unwrap(); + let page = ObjRef::new(4, 0); + let mut resources = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::Name(b"XObject".to_vec())); + form.set(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())); + form.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 200.into(), 300.into()])); + form.set(b"Resources".to_vec(), resources.clone()); + let form = doc.add(pdfcraft_cos::Stream::from_raw(form, b"/Span << /MCID 0 >> BDC BT /F1 12 Tf 10 180 Td (Form words) Tj ET EMC".to_vec())); + let mut objects = Dict::new(); + objects.set(b"Fm".to_vec(), form); + resources.set(b"XObject".to_vec(), objects); + // Inherit resources from the page tree too. + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + src.bytes = std::sync::Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let report = count(src, &[0], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap(); + assert_eq!(report.total.words, 4, "the unrelated form's MCID must not be excluded"); + assert!(report.unclassified_structure); + } + + #[test] + fn unicode_words_ignore_punctuation_and_keep_apostrophes() { + assert_eq!(Counts::of("Hello, Welt! don't … 123").words, 4); + assert_eq!(Counts::of("Привет мир").words, 2); + assert_eq!(Counts::of("\t \n...").words, 0); + assert_eq!(Counts::of("ä b").characters, 3); + assert_eq!(Counts::of("ä b").characters_without_spaces, 2); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index d1425717d6..60a0ecc66a 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -433,6 +433,14 @@ impl PageRenderer { self.stats } + /// Extract one page for statistics, with semantic exclusions. Never rasterizes or panics. + pub fn filtered_text(&mut self, page: usize, filter: crate::text::TextFilter) -> Result { + let pdf = self.pdf.as_ref().ok_or("the document could not be parsed")?; + self.stats.text_interpretations = self.stats.text_interpretations.saturating_add(1); + catch_unwind(AssertUnwindSafe(|| crate::text::extract_page_filtered(pdf, page, &self.settings, filter))) + .map_err(|e| format!("text extraction failed: {}", panic_message(&e)))? + } + /// Render one page. Never panics. pub fn render(&mut self, req: RenderRequest) -> RenderedPage { let start = Stopwatch::start(); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 92b6be98e4..ae5ffa9a9c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -14,7 +14,27 @@ use hayro::hayro_interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; + +/// Semantic exclusions for statistics. Untagged text is retained; no layout guess is made. +#[derive(Clone, Debug, Default)] +pub struct TextFilter { + pub tables: bool, + pub code: bool, + /// Exclude Figure-marked text and invisible text layers, including OCR. + pub images: bool, + /// Page-local MCIDs whose structure ancestors match the selected exclusions. + pub mcids: HashSet, +} + +impl TextFilter { + fn excludes(&self, tag: &[u8], mcid: Option) -> bool { + (self.tables && matches!(tag, b"Table" | b"TR" | b"TH" | b"TD" | b"THead" | b"TBody" | b"TFoot")) + || (self.code && tag == b"Code") + || (self.images && tag == b"Figure") + || mcid.is_some_and(|id| self.mcids.contains(&id)) + } +} mod search; @@ -338,6 +358,10 @@ struct TextDevice { actual: Option<(usize, String, Vec)>, /// Every `/ActualText` replacement made on the page. replaced: Vec, + filter: TextFilter, + excluded_depth: Option, + actual_filtered: bool, + partial_replacement: bool, } /// An `/ActualText` replacement: the glyph standing for it in `TextDevice::glyphs` (none for an @@ -402,7 +426,16 @@ fn merge(drawn: &[Drawn], text: String) -> Option { impl TextDevice { fn new() -> Self { - TextDevice { glyphs: Vec::new(), depth: 0, actual: None, replaced: Vec::new() } + TextDevice { + glyphs: Vec::new(), + depth: 0, + actual: None, + replaced: Vec::new(), + filter: TextFilter::default(), + excluded_depth: None, + actual_filtered: false, + partial_replacement: false, + } } /// Ends the open `/ActualText` replacement (ISO 32000-2 §14.9.4): its glyphs become one glyph @@ -415,6 +448,12 @@ impl TextDevice { /// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. fn end_actual_text(&mut self) { let Some((_, text, drawn)) = self.actual.take() else { return }; + if std::mem::take(&mut self.actual_filtered) { + // ActualText has no mapping from replacement words to individual glyphs. A mixed + // span cannot be filtered honestly; a wholly excluded span can simply be omitted. + self.partial_replacement |= !drawn.is_empty(); + return; + } let Some(glyph) = merge(&drawn, text.clone()) else { self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); return; @@ -516,7 +555,11 @@ impl<'a> Device<'a> for TextDevice { fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} fn push_clip_path(&mut self, _: &ClipPath) {} fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} - fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) { + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, mode: &GlyphDrawMode) { + if self.excluded_depth.is_some() || (self.filter.images && matches!(mode, GlyphDrawMode::Invisible)) { + self.actual_filtered |= self.actual.is_some(); + return; + } // Inside an `/ActualText` replacement only the glyph's box matters. let replaced = self.actual.is_some(); let text = match glyph.as_unicode() { @@ -597,12 +640,15 @@ impl<'a> Device<'a> for TextDevice { fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} fn pop_clip_path(&mut self) {} fn pop_transparency_group(&mut self) {} - fn begin_marked_content(&mut self, _: &[u8], _: Option) { + fn begin_marked_content(&mut self, tag: &[u8], mcid: Option) { self.depth = self.depth.saturating_add(1); + if self.excluded_depth.is_none() && self.filter.excludes(tag, mcid) { + self.excluded_depth = Some(self.depth); + } } fn actual_text(&mut self, text: &[u8]) { // A replacement nested in another is already covered by the outer one. - if self.actual.is_none() { + if self.actual.is_none() && self.excluded_depth.is_none() { self.actual = Some((self.depth, pdfcraft_cos::PdfString::literal(text).to_text(), Vec::new())); } } @@ -610,14 +656,21 @@ impl<'a> Device<'a> for TextDevice { if self.actual.as_ref().is_some_and(|(depth, _, _)| *depth == self.depth) { self.end_actual_text(); } + if self.excluded_depth == Some(self.depth) { + self.excluded_depth = None; + } self.depth = self.depth.saturating_sub(1); } } /// Extract the text of one page. Panics inside the interpreter are the caller's to catch. pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSettings) -> Option { + extract_page_filtered(pdf, page, settings, TextFilter::default()).ok() +} + +pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &InterpreterSettings, filter: TextFilter) -> Result { let pages = pdf.pages(); - let p = pages.get(page)?; + let p = pages.get(page).ok_or("the page could not be read")?; let (w, h) = p.render_dimensions(); let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, @@ -626,9 +679,13 @@ pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSetting let initial = p.initial_transform(true).to_kurbo(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings); let mut dev = TextDevice::new(); + dev.filter = filter; interpret_page(p, &mut ctx, &mut dev); // A replacement the content never closed ends with the page. dev.finish(); + if dev.partial_replacement { + return Err("a replacement text span mixes included and excluded content; disable the exclusions to count this page".into()); + } let glyphs = dev.glyphs; // Text on a curved path: glyphs that each continue the previous one's line while the // direction turns. Each such run is one line. @@ -743,7 +800,7 @@ pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSetting for f in extra { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } - Some(text) + Ok(text) } /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1788e7823f..5a28ce9b27 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -174,6 +174,7 @@ impl PdfCraftApp { "file.close_all" => self.close_all(), "file.revert" => self.dialog = Some(Dialog::Revert), "file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)), + "file.word_count" => self.dialog = Some(Dialog::WordCount), "protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)), // A restricted document's security changes once its permissions password is entered. "protect.password" | "protect.remove" if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.can_unlock()) => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6eceff17e1..5e091ee92a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { link_prompt(app, ctx); remote_link_prompt(app, ctx); crate::updates::dialog(app, ctx); + if app.dialog != Some(Dialog::WordCount) { + app.word_count_draft.cancel(); + } let Some(dialog) = app.dialog else { app.props_draft = None; app.view_draft = None; @@ -82,6 +85,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { _ => 520.0, }); match dialog { + Dialog::WordCount => { + close = crate::word_count_ui::body(ui, app); + } Dialog::CreateImages => { let (go, cancel) = crate::create_ui::image_import_body(ui, app); images_now = go; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ca8fe40e11..a0869d3e0b 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2378,3 +2378,19 @@ animation effects Off معطّلة Mark as artifact وضع علامة كعنصر زخرفي Remove this leaf tag from reading order and mark its direct content as an artifact أزل هذه العلامة من ترتيب القراءة وضع علامة على محتواها المباشر كعنصر زخرفي Mark content as artifact وضع علامة على المحتوى كعنصر زخرفي + Word count عدد الكلمات + Current page only الصفحة الحالية فقط + Exclude tagged tables استبعاد الجداول الموسومة + Exclude tagged code blocks استبعاد كتل التعليمات البرمجية الموسومة + Exclude image/OCR text استبعاد نص الصور والتعرف الضوئي على الحروف + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. تعتمد الاستثناءات على وسوم PDF. تبقى الجداول والتعليمات البرمجية غير الموسومة ضمن العدد. يستبعد خيار الصور النص غير المرئي أيضًا. + Words الكلمات + Characters الأحرف + Characters without spaces الأحرف دون مسافات + Pages counted الصفحات المحسوبة + Some pages contain no countable text. Scanned images need OCR first. لا تحتوي بعض الصفحات على نص قابل للعد. تتطلب الصور الممسوحة ضوئيًا التعرف الضوئي على الحروف أولًا. + Some content could not be classified safely and was kept in the count. تعذر تصنيف بعض المحتوى بشكل موثوق، لذا بقي ضمن العدد. + Counting words… {n} pages read جارٍ عد الكلمات… تمت قراءة {n} من الصفحات + Word counting stopped unexpectedly. توقف عد الكلمات بشكل غير متوقع. + Word count… عدد الكلمات… +@id file.word_count عدد الكلمات… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 706bfe2ca5..1d2ea72bd4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2320,3 +2320,19 @@ animation effects Off Изключени Mark as artifact Маркиране като артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Премахнете този листов етикет от реда на четене и маркирайте прякото му съдържание като артефакт Mark content as artifact Маркиране на съдържание като артефакт + Word count Брой думи + Current page only Само текущата страница + Exclude tagged tables Изключване на маркираните таблици + Exclude tagged code blocks Изключване на маркираните блокове с код + Exclude image/OCR text Изключване на текст от изображения/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Изключването използва PDF тагове. Немаркираните таблици и код остават включени. Изключването на изображения премахва и невидимия текст. + Words Думи + Characters Знаци + Characters without spaces Знаци без интервали + Pages counted Преброени страници + Some pages contain no countable text. Scanned images need OCR first. Някои страници не съдържат текст за преброяване. Сканираните изображения първо изискват OCR. + Some content could not be classified safely and was kept in the count. Част от съдържанието не може да бъде класифицирана надеждно и е включена в броя. + Counting words… {n} pages read Преброяване на думи… прочетени страници: {n} + Word counting stopped unexpectedly. Преброяването на думи спря неочаквано. + Word count… Брой думи… +@id file.word_count Брой думи… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 547dea2591..4431dfc52c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2431,3 +2431,19 @@ animation effects Off Vypnuto Mark as artifact Označit jako artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Odeberte tuto značku listu z pořadí čtení a označte její přímý obsah jako artefakt Mark content as artifact Označit obsah jako artefakt + Word count Počet slov + Current page only Pouze aktuální stránka + Exclude tagged tables Vynechat označené tabulky + Exclude tagged code blocks Vynechat označené bloky kódu + Exclude image/OCR text Vynechat text obrázků a OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Vynechávání využívá tagy PDF. Neoznačené tabulky a kód zůstávají zahrnuty. Vynechání obrázků odstraní i neviditelný text. + Words Slova + Characters Znaky + Characters without spaces Znaky bez mezer + Pages counted Započítané stránky + Some pages contain no countable text. Scanned images need OCR first. Některé stránky neobsahují text k počítání. U naskenovaných obrázků je nejprve nutné OCR. + Some content could not be classified safely and was kept in the count. Část obsahu nebylo možné spolehlivě zařadit a zůstala zahrnuta v počtu. + Counting words… {n} pages read Počítání slov… přečtené stránky: {n} + Word counting stopped unexpectedly. Počítání slov se neočekávaně zastavilo. + Word count… Počet slov… +@id file.word_count Počet slov… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 289a6ee939..9ea54ff507 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2358,3 +2358,19 @@ animation effects Off Aus Mark as artifact Als Artefakt markieren Remove this leaf tag from reading order and mark its direct content as an artifact Dieses Blatt-Tag aus der Lesereihenfolge entfernen und seinen direkten Inhalt als Artefakt markieren Mark content as artifact Inhalt als Artefakt markieren +@id file.word_count Wörter zählen… + Word count Wörter zählen + Word count… Wörter zählen… + Current page only Nur aktuelle Seite + Exclude tagged tables Als Tabellen gekennzeichnete Inhalte ausschließen + Exclude tagged code blocks Als Codeblöcke gekennzeichnete Inhalte ausschließen + Exclude image/OCR text Bildtext und OCR-Text ausschließen + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Ausschlüsse verwenden PDF-Tags. Tabellen und Code ohne Tags werden weiterhin gezählt. Beim Ausschluss von Bildtext wird auch unsichtbarer Text ausgeschlossen. + Words Wörter + Characters Zeichen + Characters without spaces Zeichen ohne Leerzeichen + Pages counted Gezählte Seiten + Some pages contain no countable text. Scanned images need OCR first. Einige Seiten enthalten keinen zählbaren Text. Führen Sie für gescannte Bilder zuerst eine Texterkennung durch. + Some content could not be classified safely and was kept in the count. Einige Inhalte konnten nicht sicher zugeordnet werden und wurden mitgezählt. + Counting words… {n} pages read Wörter werden gezählt… {n} Seiten gelesen + Word counting stopped unexpectedly. Die Wortzählung wurde unerwartet beendet. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 341637c85d..227a06698a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2367,3 +2367,19 @@ animation effects Off Ανενεργά Mark as artifact Σήμανση ως τεχνούργημα Remove this leaf tag from reading order and mark its direct content as an artifact Αφαιρέστε αυτήν την ετικέτα φύλλου από τη σειρά ανάγνωσης και σημειώστε το άμεσο περιεχόμενό της ως τεχνούργημα Mark content as artifact Σήμανση περιεχομένου ως τεχνούργημα + Word count Καταμέτρηση λέξεων + Current page only Μόνο η τρέχουσα σελίδα + Exclude tagged tables Εξαίρεση πινάκων με ετικέτες + Exclude tagged code blocks Εξαίρεση μπλοκ κώδικα με ετικέτες + Exclude image/OCR text Εξαίρεση κειμένου εικόνων/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Οι εξαιρέσεις χρησιμοποιούν ετικέτες PDF. Οι πίνακες και ο κώδικας χωρίς ετικέτες παραμένουν στην καταμέτρηση. Η εξαίρεση εικόνων αφαιρεί και το αόρατο κείμενο. + Words Λέξεις + Characters Χαρακτήρες + Characters without spaces Χαρακτήρες χωρίς κενά + Pages counted Σελίδες που καταμετρήθηκαν + Some pages contain no countable text. Scanned images need OCR first. Ορισμένες σελίδες δεν περιέχουν κείμενο για καταμέτρηση. Οι σαρωμένες εικόνες χρειάζονται πρώτα OCR. + Some content could not be classified safely and was kept in the count. Ορισμένο περιεχόμενο δεν ήταν δυνατό να ταξινομηθεί αξιόπιστα και παρέμεινε στην καταμέτρηση. + Counting words… {n} pages read Καταμέτρηση λέξεων… αναγνώστηκαν {n} σελίδες + Word counting stopped unexpectedly. Η καταμέτρηση λέξεων σταμάτησε απροσδόκητα. + Word count… Καταμέτρηση λέξεων… +@id file.word_count Καταμέτρηση λέξεων… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b904e8034b..01dc5853cd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2487,3 +2487,19 @@ animation effects Off Desactivados Mark as artifact Marcar como artefacto Remove this leaf tag from reading order and mark its direct content as an artifact Quite esta etiqueta hoja del orden de lectura y marque su contenido directo como artefacto Mark content as artifact Marcar contenido como artefacto + Word count Recuento de palabras + Current page only Solo la página actual + Exclude tagged tables Excluir tablas etiquetadas + Exclude tagged code blocks Excluir bloques de código etiquetados + Exclude image/OCR text Excluir texto de imágenes/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Las exclusiones usan etiquetas PDF. Las tablas y el código sin etiquetas siguen incluidos. Excluir imágenes también elimina el texto invisible. + Words Palabras + Characters Caracteres + Characters without spaces Caracteres sin espacios + Pages counted Páginas contadas + Some pages contain no countable text. Scanned images need OCR first. Algunas páginas no contienen texto que se pueda contar. Las imágenes escaneadas requieren OCR primero. + Some content could not be classified safely and was kept in the count. Parte del contenido no se pudo clasificar de forma fiable y se mantuvo en el recuento. + Counting words… {n} pages read Contando palabras… {n} páginas leídas + Word counting stopped unexpectedly. El recuento de palabras se detuvo inesperadamente. + Word count… Recuento de palabras… +@id file.word_count Recuento de palabras… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6d1140e4a9..7ae7d14d10 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2357,3 +2357,19 @@ animation effects Off Désactivés Mark as artifact Marquer comme artefact Remove this leaf tag from reading order and mark its direct content as an artifact Retirez cette balise feuille de l’ordre de lecture et marquez son contenu direct comme artefact Mark content as artifact Marquer le contenu comme artefact + Word count Nombre de mots + Current page only Page actuelle uniquement + Exclude tagged tables Exclure les tableaux balisés + Exclude tagged code blocks Exclure les blocs de code balisés + Exclude image/OCR text Exclure le texte des images/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Les exclusions utilisent les balises PDF. Les tableaux et le code non balisés restent inclus. L’exclusion des images retire aussi le texte invisible. + Words Mots + Characters Caractères + Characters without spaces Caractères sans espaces + Pages counted Pages comptées + Some pages contain no countable text. Scanned images need OCR first. Certaines pages ne contiennent aucun texte à compter. Les images numérisées nécessitent d’abord une reconnaissance OCR. + Some content could not be classified safely and was kept in the count. Certains contenus n’ont pas pu être classés de manière fiable et restent inclus dans le décompte. + Counting words… {n} pages read Comptage des mots… {n} pages lues + Word counting stopped unexpectedly. Le comptage des mots s’est arrêté de manière inattendue. + Word count… Nombre de mots… +@id file.word_count Nombre de mots… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 38744f2e0c..254214f4d4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2356,3 +2356,19 @@ animation effects Off Ki Mark as artifact Megjelölés műtárgyként Remove this leaf tag from reading order and mark its direct content as an artifact Távolítsa el ezt a levélcímkét az olvasási sorrendből, és jelölje meg a közvetlen tartalmát műtárgyként Mark content as artifact Tartalom megjelölése műtárgyként + Word count Szavak száma + Current page only Csak az aktuális oldal + Exclude tagged tables Címkézett táblázatok kizárása + Exclude tagged code blocks Címkézett kódblokkok kizárása + Exclude image/OCR text Képek és OCR szövegének kizárása + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. A kizárások PDF-címkéket használnak. A címkézetlen táblázatok és kódok továbbra is beleszámítanak. A képek kizárása a láthatatlan szöveget is eltávolítja. + Words Szavak + Characters Karakterek + Characters without spaces Karakterek szóközök nélkül + Pages counted Megszámolt oldalak + Some pages contain no countable text. Scanned images need OCR first. Egyes oldalakon nincs megszámolható szöveg. A beolvasott képeken először OCR-t kell futtatni. + Some content could not be classified safely and was kept in the count. Egyes tartalmakat nem lehetett megbízhatóan besorolni, ezért beleszámítanak az eredménybe. + Counting words… {n} pages read Szavak számlálása… {n} oldal beolvasva + Word counting stopped unexpectedly. A szavak számlálása váratlanul leállt. + Word count… Szavak száma… +@id file.word_count Szavak száma… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 01beadf966..47b32de762 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2366,3 +2366,19 @@ animation effects Off Disattivati Mark as artifact Segna come artefatto Remove this leaf tag from reading order and mark its direct content as an artifact Rimuovi questo tag foglia dall'ordine di lettura e segna il suo contenuto diretto come artefatto Mark content as artifact Segna il contenuto come artefatto + Word count Conteggio parole + Current page only Solo la pagina corrente + Exclude tagged tables Escludi le tabelle con tag + Exclude tagged code blocks Escludi i blocchi di codice con tag + Exclude image/OCR text Escludi il testo di immagini/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Le esclusioni usano i tag PDF. Le tabelle e il codice senza tag restano inclusi. L’esclusione delle immagini rimuove anche il testo invisibile. + Words Parole + Characters Caratteri + Characters without spaces Caratteri senza spazi + Pages counted Pagine conteggiate + Some pages contain no countable text. Scanned images need OCR first. Alcune pagine non contengono testo da contare. Le immagini acquisite richiedono prima il riconoscimento OCR. + Some content could not be classified safely and was kept in the count. Alcuni contenuti non sono stati classificati in modo affidabile e sono rimasti nel conteggio. + Counting words… {n} pages read Conteggio parole… {n} pagine lette + Word counting stopped unexpectedly. Il conteggio delle parole si è interrotto in modo imprevisto. + Word count… Conteggio parole… +@id file.word_count Conteggio parole… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index feb2bc8219..91782e93ca 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2357,3 +2357,19 @@ animation effects Off オフ Mark as artifact アーティファクトとしてマーク Remove this leaf tag from reading order and mark its direct content as an artifact このリーフタグを読み上げ順序から削除し、その直接のコンテンツをアーティファクトとしてマークします Mark content as artifact コンテンツをアーティファクトとしてマーク + Word count 文字数と単語数 + Current page only 現在のページのみ + Exclude tagged tables タグ付きの表を除外 + Exclude tagged code blocks タグ付きのコードブロックを除外 + Exclude image/OCR text 画像・OCRのテキストを除外 + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. 除外にはPDFタグを使用します。タグのない表やコードは集計に含まれます。画像の除外では非表示のテキストも除外されます。 + Words 単語数 + Characters 文字数 + Characters without spaces 空白を除く文字数 + Pages counted 集計したページ数 + Some pages contain no countable text. Scanned images need OCR first. 一部のページには集計できるテキストがありません。スキャン画像には先にOCRを実行してください。 + Some content could not be classified safely and was kept in the count. 正確に分類できなかった内容は集計に含まれています。 + Counting words… {n} pages read 単語を集計中… {n}ページ読み取り済み + Word counting stopped unexpectedly. 単語の集計が予期せず停止しました。 + Word count… 文字数と単語数… +@id file.word_count 文字数と単語数… diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 09187a4609..ec9d2e5c4a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2355,3 +2355,19 @@ animation effects Off Өшірулі Mark as artifact Артефакт ретінде белгілеу Remove this leaf tag from reading order and mark its direct content as an artifact Осы жапырақ тегін оқу ретінен алып тастап, оның тікелей мазмұнын артефакт ретінде белгілеңіз Mark content as artifact Мазмұнды артефакт ретінде белгілеу + Word count Сөздер саны + Current page only Тек ағымдағы бет + Exclude tagged tables Белгіленген кестелерді есепке алмау + Exclude tagged code blocks Белгіленген код блоктарын есепке алмау + Exclude image/OCR text Сурет/OCR мәтінін есепке алмау + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Есептен шығару PDF тегтерін пайдаланады. Тегсіз кестелер мен код есепте қалады. Суреттерді алып тастау көрінбейтін мәтінді де алып тастайды. + Words Сөздер + Characters Таңбалар + Characters without spaces Бос орынсыз таңбалар + Pages counted Есептелген беттер + Some pages contain no countable text. Scanned images need OCR first. Кейбір беттерде санауға болатын мәтін жоқ. Сканерленген суреттерге алдымен OCR қажет. + Some content could not be classified safely and was kept in the count. Кейбір мазмұнды сенімді түрде жіктеу мүмкін болмады, сондықтан ол есепте қалды. + Counting words… {n} pages read Сөздер саналуда… {n} бет оқылды + Word counting stopped unexpectedly. Сөздерді санау күтпеген жерден тоқтады. + Word count… Сөздер саны… +@id file.word_count Сөздер саны… diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2f723a4524..08e3b3d422 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2601,3 +2601,19 @@ key Space Tarpas Mark as artifact Pažymėti kaip artefaktą Remove this leaf tag from reading order and mark its direct content as an artifact Pašalinkite šią lapo žymą iš skaitymo tvarkos ir pažymėkite jos tiesioginį turinį kaip artefaktą Mark content as artifact Pažymėti turinį kaip artefaktą + Word count Žodžių skaičius + Current page only Tik dabartinis puslapis + Exclude tagged tables Neįtraukti pažymėtų lentelių + Exclude tagged code blocks Neįtraukti pažymėtų kodo blokų + Exclude image/OCR text Neįtraukti vaizdų ir OCR teksto + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Išimtys naudoja PDF žymas. Nepažymėtos lentelės ir kodas lieka įtraukti. Vaizdų išimtis pašalina ir nematomą tekstą. + Words Žodžiai + Characters Simboliai + Characters without spaces Simboliai be tarpų + Pages counted Suskaičiuoti puslapiai + Some pages contain no countable text. Scanned images need OCR first. Kai kuriuose puslapiuose nėra skaičiuojamo teksto. Nuskaitytiems vaizdams pirmiausia reikia OCR. + Some content could not be classified safely and was kept in the count. Dalies turinio nepavyko patikimai klasifikuoti, todėl jis liko įtrauktas į skaičių. + Counting words… {n} pages read Skaičiuojami žodžiai… perskaityta puslapių: {n} + Word counting stopped unexpectedly. Žodžių skaičiavimas netikėtai sustojo. + Word count… Žodžių skaičius… +@id file.word_count Žodžių skaičius… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7dd7c0dba6..4d777c46ee 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2337,3 +2337,19 @@ animation effects Off Wyłączone Mark as artifact Oznacz jako artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Usuń ten znacznik liścia z kolejności odczytu i oznacz jego bezpośrednią zawartość jako artefakt Mark content as artifact Oznacz zawartość jako artefakt + Word count Liczba słów + Current page only Tylko bieżąca strona + Exclude tagged tables Wyklucz oznaczone tabele + Exclude tagged code blocks Wyklucz oznaczone bloki kodu + Exclude image/OCR text Wyklucz tekst obrazów/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Wykluczenia używają znaczników PDF. Nieoznaczone tabele i kod nadal są uwzględniane. Wykluczenie obrazów usuwa również niewidoczny tekst. + Words Słowa + Characters Znaki + Characters without spaces Znaki bez spacji + Pages counted Policzone strony + Some pages contain no countable text. Scanned images need OCR first. Niektóre strony nie zawierają tekstu do policzenia. Zeskanowane obrazy wymagają najpierw OCR. + Some content could not be classified safely and was kept in the count. Części treści nie udało się wiarygodnie sklasyfikować, więc pozostała uwzględniona w liczbie. + Counting words… {n} pages read Liczenie słów… odczytane strony: {n} + Word counting stopped unexpectedly. Liczenie słów zostało nieoczekiwanie przerwane. + Word count… Liczba słów… +@id file.word_count Liczba słów… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4513451624..61f6d67b12 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2371,3 +2371,19 @@ animation effects Off Desativados Mark as artifact Marcar como artefato Remove this leaf tag from reading order and mark its direct content as an artifact Remova esta tag folha da ordem de leitura e marque seu conteúdo direto como artefato Mark content as artifact Marcar conteúdo como artefato + Word count Contagem de palavras + Current page only Somente a página atual + Exclude tagged tables Excluir tabelas com tags + Exclude tagged code blocks Excluir blocos de código com tags + Exclude image/OCR text Excluir texto de imagens/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. As exclusões usam tags de PDF. Tabelas e código sem tags continuam incluídos. A exclusão de imagens também remove o texto invisível. + Words Palavras + Characters Caracteres + Characters without spaces Caracteres sem espaços + Pages counted Páginas contadas + Some pages contain no countable text. Scanned images need OCR first. Algumas páginas não contêm texto para contar. As imagens digitalizadas precisam primeiro de OCR. + Some content could not be classified safely and was kept in the count. Parte do conteúdo não pôde ser classificada de forma confiável e foi mantida na contagem. + Counting words… {n} pages read Contando palavras… {n} páginas lidas + Word counting stopped unexpectedly. A contagem de palavras parou inesperadamente. + Word count… Contagem de palavras… +@id file.word_count Contagem de palavras… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 890e72e9a7..10f02a5532 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2270,3 +2270,19 @@ animation effects Off Выкл Mark as artifact Пометить как артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Удалите этот конечный тег из порядка чтения и пометьте его непосредственное содержимое как артефакт Mark content as artifact Пометить содержимое как артефакт + Word count Количество слов + Current page only Только текущая страница + Exclude tagged tables Исключить таблицы с тегами + Exclude tagged code blocks Исключить блоки кода с тегами + Exclude image/OCR text Исключить текст изображений и OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Исключения используют теги PDF. Таблицы и код без тегов остаются в подсчёте. Исключение изображений также удаляет невидимый текст. + Words Слова + Characters Символы + Characters without spaces Символы без пробелов + Pages counted Подсчитанные страницы + Some pages contain no countable text. Scanned images need OCR first. На некоторых страницах нет текста для подсчёта. Для отсканированных изображений сначала требуется OCR. + Some content could not be classified safely and was kept in the count. Часть содержимого не удалось надёжно классифицировать, поэтому она осталась в подсчёте. + Counting words… {n} pages read Подсчёт слов… прочитано страниц: {n} + Word counting stopped unexpectedly. Подсчёт слов неожиданно остановился. + Word count… Количество слов… +@id file.word_count Количество слов… diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index adafdca009..9de0d84a4a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2362,3 +2362,19 @@ animation effects Off Izklopljeni Mark as artifact Označi kot artefakt Remove this leaf tag from reading order and mark its direct content as an artifact Odstranite to listno oznako iz vrstnega reda branja in označite njeno neposredno vsebino kot artefakt Mark content as artifact Označi vsebino kot artefakt + Word count Število besed + Current page only Samo trenutna stran + Exclude tagged tables Izključi označene tabele + Exclude tagged code blocks Izključi označene bloke kode + Exclude image/OCR text Izključi besedilo slik/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Izključitve uporabljajo oznake PDF. Neoznačene tabele in koda ostanejo vključene. Izključitev slik odstrani tudi nevidno besedilo. + Words Besede + Characters Znaki + Characters without spaces Znaki brez presledkov + Pages counted Preštete strani + Some pages contain no countable text. Scanned images need OCR first. Nekatere strani ne vsebujejo besedila za štetje. Optično prebrane slike najprej potrebujejo OCR. + Some content could not be classified safely and was kept in the count. Nekatere vsebine ni bilo mogoče zanesljivo razvrstiti, zato je ostala vključena v štetje. + Counting words… {n} pages read Štetje besed… prebrane strani: {n} + Word counting stopped unexpectedly. Štetje besed se je nepričakovano ustavilo. + Word count… Število besed… +@id file.word_count Število besed… diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 6a56688acd..4f91019090 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2270,3 +2270,19 @@ animation effects Off ఆఫ్ Mark as artifact ఆర్టిఫాక్ట్‌గా గుర్తించు Remove this leaf tag from reading order and mark its direct content as an artifact ఈ లీఫ్ ట్యాగ్‌ను పఠన క్రమం నుంచి తీసివేసి, దాని ప్రత్యక్ష కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించండి Mark content as artifact కంటెంట్‌ను ఆర్టిఫాక్ట్‌గా గుర్తించు + Word count పదాల లెక్క + Current page only ప్రస్తుత పేజీ మాత్రమే + Exclude tagged tables ట్యాగ్ చేసిన పట్టికలను మినహాయించు + Exclude tagged code blocks ట్యాగ్ చేసిన కోడ్ బ్లాక్‌లను మినహాయించు + Exclude image/OCR text చిత్రాలు/OCR వచనాన్ని మినహాయించు + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. మినహాయింపులు PDF ట్యాగ్‌లను ఉపయోగిస్తాయి. ట్యాగ్‌లు లేని పట్టికలు, కోడ్ లెక్కలో ఉంటాయి. చిత్రాలను మినహాయిస్తే కనిపించని వచనం కూడా తొలగించబడుతుంది. + Words పదాలు + Characters అక్షరాలు + Characters without spaces ఖాళీలు లేని అక్షరాలు + Pages counted లెక్కించిన పేజీలు + Some pages contain no countable text. Scanned images need OCR first. కొన్ని పేజీల్లో లెక్కించగల వచనం లేదు. స్కాన్ చేసిన చిత్రాలకు ముందుగా OCR అవసరం. + Some content could not be classified safely and was kept in the count. కొంత కంటెంట్‌ను విశ్వసనీయంగా వర్గీకరించలేకపోయాం, కాబట్టి దాన్ని లెక్కలో ఉంచాం. + Counting words… {n} pages read పదాలను లెక్కిస్తోంది… {n} పేజీలు చదివింది + Word counting stopped unexpectedly. పదాల లెక్క అనుకోకుండా ఆగిపోయింది. + Word count… పదాల లెక్క… +@id file.word_count పదాల లెక్క… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index af37483b4a..6283ad5819 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2353,3 +2353,19 @@ animation effects Off Kapalı Mark as artifact Yapıt olarak işaretle Remove this leaf tag from reading order and mark its direct content as an artifact Bu yaprak etiketini okuma sırasından kaldırın ve doğrudan içeriğini yapıt olarak işaretleyin Mark content as artifact İçeriği yapıt olarak işaretle + Word count Sözcük sayısı + Current page only Yalnızca geçerli sayfa + Exclude tagged tables Etiketli tabloları hariç tut + Exclude tagged code blocks Etiketli kod bloklarını hariç tut + Exclude image/OCR text Görüntü/OCR metnini hariç tut + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Hariç tutma işlemleri PDF etiketlerini kullanır. Etiketsiz tablolar ve kod sayılmaya devam eder. Görüntüleri hariç tutmak görünmez metni de kaldırır. + Words Sözcükler + Characters Karakterler + Characters without spaces Boşluksuz karakterler + Pages counted Sayılan sayfalar + Some pages contain no countable text. Scanned images need OCR first. Bazı sayfalarda sayılabilecek metin yok. Taranmış görüntüler için önce OCR gerekir. + Some content could not be classified safely and was kept in the count. Bazı içerikler güvenilir biçimde sınıflandırılamadığı için sayıma dahil edildi. + Counting words… {n} pages read Sözcükler sayılıyor… {n} sayfa okundu + Word counting stopped unexpectedly. Sözcük sayımı beklenmedik şekilde durdu. + Word count… Sözcük sayısı… +@id file.word_count Sözcük sayısı… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c0c4e83d1d..86944c469e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2379,3 +2379,19 @@ animation effects Off Вимкнено Mark as artifact Позначити як артефакт Remove this leaf tag from reading order and mark its direct content as an artifact Вилучіть цей кінцевий тег із порядку читання та позначте його безпосередній вміст як артефакт Mark content as artifact Позначити вміст як артефакт + Word count Кількість слів + Current page only Лише поточна сторінка + Exclude tagged tables Виключити таблиці з тегами + Exclude tagged code blocks Виключити блоки коду з тегами + Exclude image/OCR text Виключити текст зображень та OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Виключення використовують теги PDF. Таблиці й код без тегів залишаються в підрахунку. Виключення зображень також видаляє невидимий текст. + Words Слова + Characters Символи + Characters without spaces Символи без пробілів + Pages counted Пораховані сторінки + Some pages contain no countable text. Scanned images need OCR first. Деякі сторінки не містять тексту для підрахунку. Для відсканованих зображень спочатку потрібне OCR. + Some content could not be classified safely and was kept in the count. Частину вмісту не вдалося надійно класифікувати, тому вона залишилася в підрахунку. + Counting words… {n} pages read Підрахунок слів… прочитано сторінок: {n} + Word counting stopped unexpectedly. Підрахунок слів несподівано зупинився. + Word count… Кількість слів… +@id file.word_count Кількість слів… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f914db633d..9cd784dcae 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2321,3 +2321,19 @@ animation effects Off 关 Mark as artifact 标记为工件 Remove this leaf tag from reading order and mark its direct content as an artifact 从阅读顺序中移除此叶标签,并将其直接内容标记为工件 Mark content as artifact 将内容标记为工件 + Word count 字词统计 + Current page only 仅当前页 + Exclude tagged tables 排除带标签的表格 + Exclude tagged code blocks 排除带标签的代码块 + Exclude image/OCR text 排除图像/OCR文本 + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. 排除规则使用PDF标签。无标签的表格和代码仍计入统计。排除图像时也会排除不可见文本。 + Words 词数 + Characters 字符数 + Characters without spaces 不含空白的字符数 + Pages counted 已统计页数 + Some pages contain no countable text. Scanned images need OCR first. 部分页面没有可统计的文本。扫描图像需要先进行OCR识别。 + Some content could not be classified safely and was kept in the count. 部分内容无法可靠分类,已保留在统计中。 + Counting words… {n} pages read 正在统计字词… 已读取{n}页 + Word counting stopped unexpectedly. 字词统计意外停止。 + Word count… 字词统计… +@id file.word_count 字词统计… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6f6d0ca2f7..3a3f5ad6d1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2311,3 +2311,19 @@ animation effects Off 關 Drawing scale changes. Use Actual size for full-scale plans. 圖面比例會改變。如需原尺寸的圖面,請使用「實際大小」。 Done adding text 完成新增文字 Discard this text (Esc) 捨棄此文字(Esc) + Word count 字詞統計 + Current page only 僅目前頁面 + Exclude tagged tables 排除帶標籤的表格 + Exclude tagged code blocks 排除帶標籤的程式碼區塊 + Exclude image/OCR text 排除影像/OCR文字 + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. 排除規則使用PDF標籤。沒有標籤的表格和程式碼仍計入統計。排除影像時也會排除不可見文字。 + Words 詞數 + Characters 字元數 + Characters without spaces 不含空白的字元數 + Pages counted 已統計頁數 + Some pages contain no countable text. Scanned images need OCR first. 部分頁面沒有可統計的文字。掃描影像需要先進行OCR辨識。 + Some content could not be classified safely and was kept in the count. 部分內容無法可靠分類,已保留在統計中。 + Counting words… {n} pages read 正在統計字詞… 已讀取{n}頁 + Word counting stopped unexpectedly. 字詞統計意外停止。 + Word count… 字詞統計… +@id file.word_count 字詞統計… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b01a1e9bf6..aef83403df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,6 +96,7 @@ mod print_ui; mod signature_drag; pub mod units; mod web_print; +mod word_count_ui; pub use print_ui::{Handling as PrintHandling, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; @@ -262,6 +263,7 @@ struct GridDrop { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { + WordCount, CreateImages, Properties(PropsTab), About, @@ -740,6 +742,7 @@ pub struct PdfCraftApp { pub bookmark_rename: Option<(Vec, String)>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, + pub(crate) word_count_draft: word_count_ui::Draft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// Set Page Boxes dialog state. @@ -1042,6 +1045,7 @@ impl PdfCraftApp { text_style: content_ui::default_style(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, + word_count_draft: word_count_ui::Draft::default(), } } @@ -2418,6 +2422,7 @@ impl PdfCraftApp { "watermark" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)), "background" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Background)), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), + "word-count" => Some(Dialog::WordCount), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), "accessibility-options" => Some(Dialog::AccessibilityOptions), diff --git a/crates/ui-egui/src/word_count_ui.rs b/crates/ui-egui/src/word_count_ui.rs new file mode 100644 index 0000000000..1aa999c496 --- /dev/null +++ b/crates/ui-egui/src/word_count_ui.rs @@ -0,0 +1,225 @@ +//! Read-only statistics for a document snapshot. At most one worker per dialog, with cancellation +//! between pages and no stale results after edits, page changes or changed exclusions. + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, +}; + +use pdfcraft_engine::{ + DocId, + export::ExportSource, + word_count::{self, Options, Report}, +}; + +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Key { + doc: DocId, + generation: u64, + page: Option, + options: Options, +} + +struct Job { + key: Key, + receiver: mpsc::Receiver>, + cancel: Arc, + done: Arc, +} + +impl Drop for Job { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub(crate) struct Draft { + options: Options, + current_page: bool, + key: Option, + job: Option, + result: Option>, +} + +impl Draft { + pub(crate) fn cancel(&mut self) { + if let Some(job) = &self.job { + job.cancel.store(true, Ordering::Relaxed); + } + self.key = None; + self.result = None; + } + + fn update(&mut self, key: Key, source: impl FnOnce() -> ExportSource, ctx: &egui::Context, inline: bool) { + if self.key != Some(key) { + self.cancel(); + self.key = Some(key); + } + if let Some(job) = &self.job { + let received = match job.receiver.try_recv() { + Ok(result) => Some(result), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(Err(tl!("Word counting stopped unexpectedly.").into())), + }; + if let Some(result) = received { + if job.key == key && !job.cancel.load(Ordering::Relaxed) { + self.result = Some(result); + } + self.job = None; + } + } + if self.job.is_some() || self.result.is_some() { + return; + } + let source = source(); + let pages: Vec<_> = key.page.map_or_else(|| (0..source.pages).collect(), |page| vec![page]); + let (sender, receiver) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicUsize::new(0)); + self.job = Some(Job { key, receiver, cancel: cancel.clone(), done: done.clone() }); + let ctx = ctx.clone(); + let work = move || { + let result = word_count::count(source, &pages, key.options, |n| { + done.store(n, Ordering::Relaxed); + ctx.request_repaint(); + !cancel.load(Ordering::Relaxed) + }); + let _ = sender.send(result); + ctx.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if !inline { + if let Err(error) = std::thread::Builder::new().name("word-count".into()).spawn(work) { + self.job = None; + self.result = Some(Err(error.to_string())); + } + return; + } + let _ = inline; + work(); + } +} + +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> bool { + ui.heading(tl!("Word count")); + let Some((view, id)) = app.active_ids() else { return true }; + let Some(doc) = app.session.get(id) else { return true }; + if let Err(error) = doc.check_copying() { + app.word_count_draft.cancel(); + ui.label(error.to_string()); + return false; + } + let draft = &mut app.word_count_draft; + ui.add(widgets::checkbox(&mut draft.current_page, tl!("Current page only"))); + ui.separator(); + ui.add(widgets::checkbox(&mut draft.options.exclude_tables, tl!("Exclude tagged tables"))); + ui.add(widgets::checkbox(&mut draft.options.exclude_code, tl!("Exclude tagged code blocks"))); + ui.add(widgets::checkbox(&mut draft.options.exclude_images, tl!("Exclude image/OCR text"))); + ui.label(tl!("Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text.")); + let key = Key { doc: id, generation: doc.edit_generation(), page: draft.current_page.then_some(app.views[view].current), options: draft.options }; + draft.update(key, || doc.export_source(), ui.ctx(), app.run_inline); + ui.separator(); + match &draft.result { + Some(Ok(report)) => { + egui::Grid::new("word-count-totals").show(ui, |ui| { + for (label, value) in [ + (tl!("Words"), report.total.words), + (tl!("Characters"), report.total.characters), + (tl!("Characters without spaces"), report.total.characters_without_spaces), + (tl!("Pages counted"), report.pages.len()), + ] { + ui.label(label); + ui.strong(value.to_string()); + ui.end_row(); + } + }); + if report.pages_without_text > 0 { + ui.label(tl!("Some pages contain no countable text. Scanned images need OCR first.")); + } + if report.unclassified_structure { + ui.label(tl!("Some content could not be classified safely and was kept in the count.")); + } + } + Some(Err(error)) => { + ui.colored_label(ui.visuals().error_fg_color, error); + } + None => { + ui.spinner(); + let done = draft.job.as_ref().map_or(0, |job| job.done.load(Ordering::Relaxed)); + ui.label(crate::i18n::fmt(tl!("Counting words… {n} pages read"), &[("n", &done.to_string())])); + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn word_count_dialog_counts_changes_pages_and_closes() { + for inline in [true, false] { + let bytes = pdfcraft_engine::Session::new().create_from_text("Words", "Hello world\u{c}Another three words").unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.run_inline = inline; + app.set_option("language", "en").unwrap(); + app.open_bytes("words.pdf", None, bytes.as_ref().clone()).unwrap(); + app.run_command("file.word_count"); + app + }); + let wait = |h: &mut Harness<'_, PdfCraftApp>| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().word_count_draft.result.is_none() { + assert!(std::time::Instant::now() < deadline, "word-count worker timed out"); + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + h.run_steps(2); + }; + wait(&mut h); + assert_eq!(h.state().dialog, Some(Dialog::WordCount)); + assert_eq!(h.state().word_count_draft.result.as_ref().unwrap().as_ref().unwrap().total.words, 5); + h.get_by_label("Current page only").click(); + h.run_steps(1); + wait(&mut h); + assert_eq!(h.state().word_count_draft.result.as_ref().unwrap().as_ref().unwrap().total.words, 2); + h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0] }); + h.run_steps(1); + wait(&mut h); + assert_eq!(h.state().word_count_draft.result.as_ref().unwrap().as_ref().unwrap().total.words, 3); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(h.state().word_count_draft.result.is_none()); + } + } + + #[test] + fn word_count_cancelled_worker_cannot_publish_an_old_result() { + let key = Key { doc: DocId(1), generation: 0, page: None, options: Options::default() }; + let (tx, rx) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let mut draft = Draft { + key: Some(key), + job: Some(Job { key, receiver: rx, cancel: cancel.clone(), done: Arc::new(AtomicUsize::new(0)) }), + ..Draft::default() + }; + draft.cancel(); + assert!(cancel.load(Ordering::Relaxed)); + let bytes = pdfcraft_engine::Session::new().create_blank(200.0, 300.0, 1).unwrap(); + let source = || ExportSource { bytes: bytes.clone(), pages: 1, sizes: vec![(200.0, 300.0)], config: Default::default() }; + draft.update(key, source, &egui::Context::default(), true); + assert!(draft.result.is_none()); // still one worker, no competing replacement + tx.send(Ok(Report { total: word_count::Counts { words: 999, ..Default::default() }, ..Default::default() })).unwrap(); + draft.update(key, source, &egui::Context::default(), true); + draft.update(key, source, &egui::Context::default(), true); + assert_eq!(draft.result.unwrap().unwrap().total.words, 0); + } +} diff --git a/docs/word-count.md b/docs/word-count.md new file mode 100644 index 0000000000..4b3d02cba4 --- /dev/null +++ b/docs/word-count.md @@ -0,0 +1,52 @@ +# Word count + +File ▸ Word count counts the current document, including unsaved edits. Select **Current page +only** to count just the displayed page. On desktop, counting runs in the background, one page +at a time. Closing the dialog or changing its options cancels the old count between pages. + +Words use Unicode word boundaries (UAX #29); punctuation alone is not a word. Characters are +Unicode scalar values, not bytes or displayed glyphs. “Characters without spaces” removes all +Unicode whitespace. Characters include the line breaks and spaces reconstructed by text +extraction. The total is the sum of page counts, with no extra separator between pages. + +The three independent exclusions are off by default: + +- **Tagged tables:** Table, TR, TH, TD, THead, TBody and TFoot content. +- **Tagged code blocks:** Code content. +- **Image/OCR text:** Figure content and invisible text, including typical OCR layers. Invisible + text can have other uses too. Visible OCR text without Figure tags is not identifiable as OCR. + +Tags can be explicit marked-content tags or page-local MCIDs classified through the PDF's +structure tree, including parent roles and RoleMap aliases. These are semantic exclusions, not +visual table/code recognition. Untagged or unsupported structure stays in the count; the dialog +reports when structure classification is incomplete. On pages with Form XObjects, structure-tree +MCID exclusions are conservatively disabled because forms have separate MCID namespaces. +Explicit Table/Code/Figure marked content still works there. Cyclic and oversized structure +graphs are bounded and reported as incomplete. + +PDF `/ActualText` replacements are respected. If one replacement spans both included and +excluded glyphs, there is no reliable mapping of its words to the selected content: counting +fails with an explanation instead of silently returning the wrong total. Disable exclusions to +count that page. Unreadable pages likewise fail the operation, without a misleading partial total. + +Images alone contain no extractable words. Run Scan & OCR first when counting scanned pages. +Copy restrictions are respected. Counting never edits, rasterizes or saves the document, and +keeps only one page's extracted text at a time, plus the resulting page statistics. + +## CLI and MCP + +`text_word_count` is the shared read-only automation tool: + +```json +{"tool":"text_word_count","args":{"doc":1,"pages":[1,3],"exclude_tables":true,"exclude_code":true,"exclude_images":false}} +``` + +Omit `pages` to count all pages. Page numbers are 1-based; duplicates count once. The result +contains `total`, per-page `pages` entries with `page` and `counts`, `pages_without_text`, and +`unclassified_structure`. Each count has `words`, `characters`, and `characters_without_spaces`. +`pages_without_text` is measured after exclusions, so it can also include pages whose entire +text was deliberately excluded. + +The engine entry point is `pdfcraft_engine::word_count::count`. Its page indexes are 0-based; +its progress callback can cancel between pages. Frontends must enforce document copy +permissions before supplying an `ExportSource`. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a7c5f8300d..c9f302cf1a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -13,6 +13,18 @@ areas = { A = "core", B = "view", C = "edit", D = "organize", E = "comment", F = # A. Core document model and fidelity # ================================================================================================== +[[feature]] +id = "core.word-count" +title = "Word and character count" +area = "A" +tier = "P2" +milestone = "M2" +status = "shipped" +commands = ["file.word_count"] +tools = ["text_word_count"] +evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] +notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md." + [[feature]] id = "core.open-pdf" title = "Open PDF files" From 72a46facb8a39de709f985d2811f6ad580729649 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:24:55 +0500 Subject: [PATCH 044/207] M7: read the page once per keystroke when measuring a text draft (#1781) `text_edit_layout`, which the inline editor calls on every keystroke to size the frame, interpreted the page's content four times: `text_blocks` for the paragraphs, `text_lines` again for the untouched runs, once more inside `rewrite_block` on the still unchanged clone, and finally for the rewritten draft. The first three read the same, unchanged page. It now reads the page's lines once, groups the paragraphs from them, and hands them to the rewrite through `rewrite_block_from`, which `rewrite_block_with_limit` also uses after reading the lines itself. The draft is still read back once after the rewrite. On a page of 120 runs in 80 paragraphs, 50 keystrokes in a release build took 0.87 ms each, against 1.25 ms before (the fastest of three runs each). No behaviour changes. The new test checks that the measured draft frame is the extent an applied edit actually draws, for a longer row, a shorter one and an added row, so sharing the lines can't drift from a fresh read. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/edit/src/tests.rs | 29 +++++++++++++++++++++++++++++ crates/edit/src/text.rs | 22 +++++++++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 555e608df7..e72af1acc1 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3713,3 +3713,32 @@ fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_ch assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); assert_eq!(page_content_bytes(&doc, 0), before); } + +#[test] +fn draft_layout_matches_the_paragraph_an_applied_edit_draws() { + let content = "BT /F1 12 Tf 14 TL 72 700 Td (A first paragraph of words) Tj T* (with a second row) Tj ET \ + BT /F1 12 Tf 72 600 Td (Another paragraph below) Tj ET BT /F1 12 Tf 320 700 Td (Side note) Tj ET"; + let doc = text_page(content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("A first")).unwrap(); + let source = text::text_blocks(&doc, 0).unwrap()[block].clone(); + for draft in [ + source.editor_text.replacen("words", "many more words than before", 1), + source.editor_text.replacen(" of words", "", 1), + format!("{}\nand a third row", source.editor_text), + ] { + let layout = text::text_edit_layout(&doc, 0, block, &draft, &text::BlockStyle::default()).unwrap(); + // Apply the same edit and measure every run that isn't one of the page's other runs. + let mut applied = doc.clone(); + text::rewrite_block(&mut applied, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap(); + let others: Vec<_> = + text::text_lines(&doc, 0).unwrap().into_iter().enumerate().filter(|(i, _)| !source.lines.contains(i)).map(|(_, l)| l.rect).collect(); + let drawn = text::text_lines(&applied, 0) + .unwrap() + .into_iter() + .map(|l| l.rect) + .filter(|r| !others.contains(r)) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]); + assert_eq!(layout.draft_rect, drawn, "{draft:?}"); + assert_eq!(layout.source_rect, source.rect); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index d434d01a95..07ce62ade7 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -140,7 +140,10 @@ pub struct TextEditLayout { } pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { - let original = text_blocks(doc, page)?; + // This runs on every keystroke: read the page once for the paragraphs, the untouched runs + // and the rewrite, which starts from the same unchanged page. + let lines = text_lines(doc, page)?; + let original = group_blocks(&lines); let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?; if text == source.editor_text && *style == BlockStyle::default() { return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); @@ -150,13 +153,13 @@ pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, s let key = |line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits)); let mut untouched = std::collections::BTreeMap::new(); - for (index, line) in text_lines(doc, page)?.iter().enumerate() { + for (index, line) in lines.iter().enumerate() { if !source.lines.contains(&index) { *untouched.entry(key(line)).or_insert(0_usize) += 1; } } let mut draft = doc.clone(); - rewrite_block(&mut draft, page, block, Some(text), style)?; + rewrite_block_from(&mut draft, page, lines, block, Some(text), style, MAX_TYPE3_GLYPHS)?; let mut rect: Option<[f64; 4]> = None; for line in text_lines(&draft, page)? { if let Some(count) = untouched.get_mut(&key(&line)) @@ -1734,6 +1737,19 @@ pub(crate) fn rewrite_block_with_limit( max_glyphs: usize, ) -> Result { let lines = text_lines(doc, page)?; + rewrite_block_from(doc, page, lines, block, text, style, max_glyphs) +} + +/// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. +fn rewrite_block_from( + doc: &mut Document, + page: usize, + lines: Vec, + block: usize, + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); From df0bc0ffbc737d27cdd253c7cef01328976a2a2f Mon Sep 17 00:00:00 2001 From: magdale76 Date: Thu, 15 Oct 2026 08:25:19 +0200 Subject: [PATCH 045/207] M11: merge image fragments, and the Standard and Mobile optimizer presets (closes #1783) (#1784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M11: merge image fragments, and the Standard and Mobile optimizer presets Some PDF printers draw a page picture as thousands of images one pixel row high (one real page: 3,300 strips, 4.3 MB). The optimizer works image by image, a one-row image can't be resampled, and every JPEG strip carries its own headers, so Reduce File Size saved 2 bytes on it. - pdfcraft-optimize `fragments` (Settings::merge_fragments, on by default): runs of `q … cm /Im Do Q` drawing upright 8-bit grey or RGB images in one colour space, sharing their left and right edges and stacked edge to edge (at least four, nothing else drawn between them; or, failing that, the rows drawn one under the other up to a gap) become one lossless image at their finest resolution. The image settings then resample it, and Clean Up drops the strips. A page whose content can't be read back exactly is left alone. - Presets as in Acrobat's Settings menu: Standard (Reduce File Size's choices) and Mobile (images above 144 ppi to 96 ppi, JPEG high). The PDF Optimizer dialog has a Settings menu (Custom once an option changes) and a Merge image fragments box; doc_optimize takes preset and merge_fragments and reports merged_images and fragments_merged. - A progress stage for the merge; four new strings in all 21 catalogs. On the real page: Standard/Reduce 4.3 MB → 231 KB, Mobile → 161 KB, with a rendering closer to the original than Acrobat's Mobile result (147 KB). Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 42 ++-- crates/automation/src/lib.rs | 10 +- crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 119 ++++++++++ crates/engine/src/optimizer.rs | 4 + crates/optimize/README.md | 10 + crates/optimize/src/fragments.rs | 299 ++++++++++++++++++++++++++ crates/optimize/src/images.rs | 17 +- crates/optimize/src/lib.rs | 58 ++++- crates/optimize/tests/optimize.rs | 134 ++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/kk.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/optimize_ui.rs | 20 +- crates/ui-egui/tests/create.rs | 28 +++ parity/acrobat-features.toml | 10 +- 34 files changed, 803 insertions(+), 36 deletions(-) create mode 100644 crates/optimize/src/fragments.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 4c7103baf5..5734c03954 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "a579e60a58e88ea83732609f2fb0921afa3d5d61082bea3be969db7ad6d3a12f" +sha256 = "0ef505ad4ae53d404d4474d2881f54bc3692739fe9200692fad2b3ace9036a03" # 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 = "eae078763f4d4b6267cce596d405e28102ebb872d9cb8bc375671cd4d264da9e" +sha256 = "73efe98c3a4190715f3f76d714390396887f45d2092926729832b79a194488c0" [[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 = "b94db0634220f21673b649be78e65bf5d9b2f939afd3992b1a5ab567d77677b0" +sha256 = "e0959af2ee8e317372d9076e487e41d025015460b99906482efa1a0e528802c3" [[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 = "6f2d46364ee4a703f6a05017d5cf6f2da4216bb83b577092b456db742a961d2a" +sha256 = "0a327af10b0bb916ef1b912e6797214bd0b2c18c1e0ef27d9f7a92a3cbeac5b7" [[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 = "0a44a27428691465074177508dde58475188fc3ad176d5f43a74dd4d518a2495" +sha256 = "c2cf9490a9f9c828382e2f24df3eaea511b4dfab6543ca00ac61e0014029f051" [[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 = "4320582e8212a8387bce630107e19ef2ffb78586d453d36872ac40a9bf917b1b" +sha256 = "fccce321ccb64bdfcb75e1d6d09f661fb6a738b30cc0ec1133134ea91959e3ad" [[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 = "0060ce0ff2ee45ac5d6ebfcc39eb9aec565006fee2867623e12572a1b4b80b5e" +sha256 = "64722973eaf542884ddd170dd9541649c1b1b365ad8c63d0d2864ffbecd20941" [[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 = "abeb8b6758e460d9d1fac894392f280fc2d0dadb30edce784ee0625eb318726c" +sha256 = "0475c03f0b2190998e247d6996d5473d3fd85ea6c55e582b7159b42096c63f9e" [[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 = "7fc8def1baa4680ac1821928c4b33c27ad15257b6be8ecaa43d6bad28d49d2b3" +sha256 = "6423cc0904fd4e49af25121d9fe4d67d09d684f45acbb8165d4ea7a474a3275c" [[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 = "ef898359cd992adff06d1889536aea90246ae63443f5181e71a8b8b12cdfd4d8" +sha256 = "588adcef83d5ec8e9a1e1fac093afa6afee30d94ac68e8b428ebd487cdc23c3e" [[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 = "2ee8d548ad696820a9710937d4d27d5dfe529244cd20df2664a4192ded639408" +sha256 = "9fda2deb8db4e919199ae032b9a9a220f32896462640578cd155074809e71f0d" [[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 = "e7f2727e4ee17a2f9a37bf8d8bf563c625c8700e1389de0b79c22002b8d3919c" +sha256 = "b60244d234bcec0602a10a077167861d60c86ab3a364c3dfc4b4e0466dd54872" [[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 = "b1bb541fa2a453537c383b9346d8c9504b7559004ae94a851f18348de2910694" +sha256 = "18416c5dc7b28bfb999e1f8127337d37915c67a91d8b8b3fc52e3286a11205ba" [[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 = "f90f24f1d30c8e8cdc595e2b229e1aca35125b55af0fce5b26d0befbb31f1fc1" +sha256 = "e50991d9d81a9a45e78c719c790029486e4c03045c30b10b4fee349fcc6702d0" [[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 = "5bc5f87e61cc1feb64d9ae94ec39f218aa7abe6697065647152104197a356d48" +sha256 = "73fb4046cd4fdccd8c1da4abfab90ecd0ca2395fc9a6302dd00dd4f810a14418" [[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 = "95d004849c3760587098ea0bfc658aa0d94887ff4f2a22d7f3d1565df5ddaf30" +sha256 = "e090ca970a518bbf667959181167ff8f8058b78e4239c0c62654c58450738a99" [[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 = "ca109e277cbe196394fb50323c632ff65972f9aa60941e69cbb244857930eec0" +sha256 = "a5e4cbf2fd2b1f840d31482a090a68fe58dbbd03e2187deecd1398d091c9fb8f" [[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 = "aa1b6741974392a8b246bab04169608211d6d6f19f2adb000cf033a6d1a3ef2e" +sha256 = "036dfc2952a758352933ec9ab7f1668a699ed108c8902340566f149df4bc0b79" [[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 = "895f2f8e6b44f7cf7f383e13765e62b5875938ded5135119b3e0621f6375c3d5" +sha256 = "d4ac259fc9dad897c9ca8a55eb830d2027f3387eafd641744dd7b9d43a5d1cb4" [[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 = "e5f36292f84ec43ae42479735f88c2667bef05f719ab11e47daaa517cd070f3e" +sha256 = "b0e3dacf8ce437663e3cb9946ced5b9f3cde6a62a2bf5ff8dd3d45268b264bf5" [[asset]] @@ -3363,4 +3363,4 @@ 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 = "25523c021600c5aecc778f53e707a114267a4463ab911fd83658a4c241ece6d3" +sha256 = "eb1f9d2990ab5e177be78d5d5e6d4c3cbdb0961c64df04bbd34e5d9ad5b42a3d" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 2856994733..397edbd131 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -995,11 +995,14 @@ impl Automation { } fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compression, ImageSettings, Settings}; + use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset}; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; - let mut settings = Settings::default(); + let mut settings = match a.opt_str("preset")? { + None => Preset::Standard.settings(), + Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), + }; let images = |key: &str, base: ImageSettings| -> Result { let Some(v) = a.get(key) else { return Ok(base) }; let mut s = base; @@ -1027,6 +1030,7 @@ impl Automation { settings.color = images("color", settings.color)?; settings.gray = images("gray", settings.gray)?; for (key, flag) in [ + ("merge_fragments", &mut settings.merge_fragments), ("discard_thumbnails", &mut settings.discard_thumbnails), ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), @@ -1059,6 +1063,8 @@ impl Automation { "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), + "merged_images": o.merged_images, + "fragments_merged": o.fragments_merged, "images": o.images, "images_resampled": o.images_resampled, "images_recompressed": o.images_recompressed, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4e684c7215..82bb2dfbf5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1237,12 +1237,14 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ "doc": doc(), "path": { "type": "string" }, + "preset": { "type": "string", "enum": ["standard", "mobile"] }, + "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, "discard_thumbnails": { "type": "boolean" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 38d67f6d68..0662362ba8 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4183,6 +4183,125 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +/// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px +/// wide on a 72 pt square (600 ppi), as some PDF printers write it. +fn printed_in_strips(rows: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let names: String = (0..rows).map(|r| format!("/R{r} {} 0 R ", 5 + r)).collect(); + objs.push(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec()); + objs.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Resources << /XObject << {names}>> >> /Contents 4 0 R >>").into_bytes()); + let k = 72.0 / rows as f64; + let content: String = (0..rows) + .map(|r| { + format!( + "q 72 0 0 {k} 0 {} cm /R{r} Do Q +", + (rows - 1 - r) as f64 * k + ) + }) + .collect(); + objs.push( + format!( + "<< /Length {} >> +stream +{content} +endstream", + content.len() + 1 + ) + .into_bytes(), + ); + for r in 0..rows { + let px: Vec = (0..600).map(|x| ((x + r * 7) % 256) as u8).collect(); + let mut o = format!( + "<< /Type /XObject /Subtype /Image /Width 600 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >> +stream +", + px.len() + ) + .into_bytes(); + o.extend_from_slice(&px); + o.extend_from_slice( + b" +endstream", + ); + objs.push(o); + } + let mut out = b"%PDF-1.5 +" + .to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +", + i + 1 + ) + .as_bytes(), + ); + out.extend_from_slice(o); + out.extend_from_slice( + b" +endobj +", + ); + } + let x = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offs { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{x} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + out +} + +#[test] +fn a_page_printed_in_strips_gets_small_through_tools() { + let dir = workdir("optimize-strips"); + std::fs::write(dir.join("strips.pdf"), printed_in_strips(300)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "strips.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "mobile.pdf", "preset": "mobile" })); + assert_eq!((r["merged_images"].as_u64(), r["fragments_merged"].as_u64(), r["unused_xobjects"].as_u64()), (Some(1), Some(300), Some(300)), "{r}"); + assert!(r["bytes_after"].as_u64().unwrap() * 10 < r["bytes_before"].as_u64().unwrap(), "{r}"); + // Reduce File Size merges too; with the option off, the strips stay. + let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); + assert!(reduced["bytes_after"].as_u64().unwrap() * 10 < reduced["bytes_before"].as_u64().unwrap(), "{reduced}"); + let off = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "off.pdf", "merge_fragments": false })); + assert_eq!(off["merged_images"].as_u64(), Some(0), "{off}"); + // The merged page opens and renders. + let mobile = ok(&mut a, "doc_open", json!({ "path": "mobile.pdf" }))["doc"].as_u64().unwrap(); + assert!(a.call("page_render", &json!({ "doc": mobile, "page": 1, "dpi": 72 })).is_ok()); + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "preset": "tiny" })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn initial_view_through_tools() { let dir = workdir("initial-view"); diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 569dd81b0b..ee8c957f93 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -17,6 +17,8 @@ pub enum OptimizeStage { /// Removing the user data chosen under Discard User Data. #[default] Discarding, + /// Merging the fragments of a page picture into one image. + Fragments, /// About to process image `done` (0-based) of `total`. Images { done: usize, total: usize }, /// Discarding objects and compressing unencoded streams. @@ -33,6 +35,7 @@ impl OptimizeStage { pub fn fraction(self) -> f32 { match self { OptimizeStage::Discarding => 0.0, + OptimizeStage::Fragments => 0.03, OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }, OptimizeStage::CleaningUp => 0.8, OptimizeStage::Merging => 0.85, @@ -85,6 +88,7 @@ impl OptimizeJob { let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { + optimize::Stage::Fragments => OptimizeStage::Fragments, optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total }, optimize::Stage::CleanUp => OptimizeStage::CleaningUp, }) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 7c1c81871b..8f943d41e4 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -5,10 +5,20 @@ Advanced optimization). ```rust let report = optimize(&mut doc, &Settings::default())?; // Reduce File Size's choices +let report = optimize(&mut doc, &Preset::Mobile.settings())?; // smaller, for screens // Or with progress: called before each image and the clean-up; `false` cancels. let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going)?; ``` +- **Presets:** Standard (Reduce File Size's choices: images above 225 ppi to 150 ppi, JPEG + medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. +- **Merge image fragments** (`fragments`, on by default): some PDF printers draw a page picture + as thousands of images one pixel row high. Runs of upright 8-bit grey or RGB images in one + colour space, sharing their left and right edges and stacked edge to edge (at least four, + drawn with nothing else between them), become one lossless image at their finest resolution, + which the image settings then resample; the strips are dropped by Clean Up. A real page + printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: + side-by-side tiles, rotated strips, masks. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM through form XObjects; the smallest over all uses). Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs new file mode 100644 index 0000000000..b5967eee09 --- /dev/null +++ b/crates/optimize/src/fragments.rs @@ -0,0 +1,299 @@ +//! Merge image fragments: some PDF printers draw a page picture as hundreds or thousands of +//! images one pixel row high, stacked edge to edge. Each fragment is too small to resample, and +//! every JPEG fragment carries its own headers, so a one-page document can weigh megabytes and +//! no image setting makes it smaller. Here the fragments are put back together into one image, +//! which the image settings then resample and recompress like any other. +//! +//! What counts as a run of fragments, and is merged: +//! - consecutive `q … cm /Name Do Q` groups in a page's content stream, with nothing else +//! between them, each drawing an image upright (no rotation, skew or flip); +//! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array +//! that changes anything, in a codec PdfCraft decodes; +//! - they share their left and right edges and are stacked edge to edge with no gap or overlap +//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them. +//! +//! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost +//! until the image settings downsample it; it is stored losslessly. A page whose content +//! stream can't be read back exactly is left alone. The fragments are then unused, and Clean Up +//! drops them. + +use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; +use crate::{OptimizeError, Report, Stage}; + +/// Fewest stacked images treated as fragments of one picture. +pub const MIN_FRAGMENTS: usize = 4; + +/// Most pixels a merged image may have (memory). +const MAX_PIXELS: u64 = 80_000_000; + +/// One fragment: its image, its box in the coordinates the run is drawn in, and its size. +struct Fragment { + image: ObjRef, + rect: [f64; 4], + width: u32, + height: u32, +} + +/// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix +/// and the name drawn. +fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec)> { + if !ops.get(i)?.is("q") { + return None; + } + let mut j = i + 1; + let mut m = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + while let Some(op) = ops.get(j).filter(|o| o.is("cm")) { + m = Matrix::from_operands(&op.operands)?.then(&m); + j += 1; + } + let draw = ops.get(j).filter(|o| o.is("Do"))?; + let name = draw.name(0)?.to_vec(); + ops.get(j + 1).filter(|o| o.is("Q"))?; + (j > i + 1).then_some((j + 2, m, name)) +} + +/// The image `name` draws, if it can be a fragment: its reference, size, colour space and +/// component count. +fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRef, u32, u32, Object, usize)> { + let r = xobjects.get(name).and_then(Object::as_ref)?; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { return None }; + let d = &s.dict; + if d.name(b"Subtype") != Some(b"Image") || d.int(b"BitsPerComponent") != Some(8) || d.contains(b"Mask") || d.contains(b"SMask") { + return None; + } + if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || !decodable(doc, s)? { + return None; + } + let cs = d.get(b"ColorSpace")?.clone(); + let n = components(doc, &cs)?; + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); + (w > 0 && h > 0).then_some((r, w, h, cs, n)) +} + +/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge. +/// Sorts it from the top down. +fn stacked(run: &mut [Fragment]) -> bool { + let Some(first) = run.first() else { return false }; + let [x0, _, x1, _] = first.rect; + let width = x1 - x0; + if !(width.is_finite() && width > 0.0) { + return false; + } + let tol = 1e-3 * width.max(1.0); + if run.iter().any(|f| { + (f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater) + }) { + return false; + } + run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); + run.windows(2).all(|w| match w { + [a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]), + _ => true, + }) +} + +/// The fragment groups from `i` on while each is drawn right under (or right over) the one +/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump. +fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec { + let mut run: Vec = Vec::new(); + let mut down: Option = None; + let mut j = i; + while let Some((next, m, name)) = group_at(ops, j) { + let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break }; + let [a, _, _, d, e, f] = m.0; + let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height }; + if let Some(prev) = run.last() { + let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]); + let below = (prev.rect[1] - f2.rect[3]).abs() <= tol; + let above = (f2.rect[1] - prev.rect[3]).abs() <= tol; + let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0); + match (down, below, above) { + _ if !same_x => break, + (None | Some(true), true, _) => down = Some(true), + (None | Some(false), _, true) => down = Some(false), + _ => break, + } + } + run.push(f2); + j = next; + } + run +} + +/// The op index after the first `count` fragment groups from `i`. +fn run_end(ops: &[Op], i: usize, count: usize) -> usize { + let mut j = i; + for _ in 0..count { + match group_at(ops, j) { + Some((next, ..)) => j = next, + None => break, + } + } + j +} + +/// The run's pixels as one image, top row first: `(pixels, width, height)`. +fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u32)> { + let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]); + let width = run.iter().map(|f| f.width).max()?; + // Pixel rows per unit of height: the finest fragment's. + let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); + let height = ((top - bottom) * density).round(); + if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS { + return None; + } + let height = height as u32; + let row = width as usize * n; + let mut out = vec![255u8; row * height as usize]; + for f in run { + let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); + let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); + if r1 <= r0 { + continue; + } + let obj = doc.get(f.image); + let Object::Stream(s) = &*obj else { return None }; + let px = pixels(s, n)?; + let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; + out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); + } + Some((out, width, height)) +} + +/// A name for the merged image that `xobjects` doesn't use yet. +fn fresh_name(xobjects: &Dict, k: &mut usize) -> Vec { + loop { + *k += 1; + let name = format!("PCMerged{k}").into_bytes(); + if !xobjects.contains(&name) { + return name; + } + } +} + +/// Add `name` → `image` to the page's image resources, wherever they live. +fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> Result<(), OptimizeError> { + let res = doc.get(page).as_dict().and_then(|d| d.get(b"Resources").cloned()); + let put = |d: &mut Dict| { + let mut x = d.get(b"XObject").and_then(Object::as_dict).cloned().unwrap_or_default(); + x.set(name.to_vec(), Object::Ref(image)); + d.set(b"XObject".to_vec(), Object::Dict(x)); + }; + match res { + Some(Object::Ref(r)) => match doc.get(r).as_dict().and_then(|d| d.reference(b"XObject")) { + Some(x) => doc.update_dict(x, |d| d.set(name.to_vec(), Object::Ref(image)))?, + None => doc.update_dict(r, put)?, + }, + Some(Object::Dict(d)) => match d.reference(b"XObject") { + Some(x) => doc.update_dict(x, |d| d.set(name.to_vec(), Object::Ref(image)))?, + None => doc.update_dict(page, |p| { + let mut d = d.clone(); + put(&mut d); + p.set(b"Resources".to_vec(), Object::Dict(d)); + })?, + }, + // Inherited (or none): the page gets its own copy with the new image. + _ => { + let mut d = page_resources(doc, page).unwrap_or_default(); + if let Some(x) = d.reference(b"XObject") { + let x = doc.get(x).as_dict().cloned().unwrap_or_default(); + d.set(b"XObject".to_vec(), Object::Dict(x)); + } + put(&mut d); + doc.update_dict(page, |p| p.set(b"Resources".to_vec(), Object::Dict(d)))?; + } + } + Ok(()) +} + +/// Merge the fragment runs of one page; returns how many fragments were merged. +fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { + let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; + let parsed = parse(&content_bytes(doc, &contents)); + if parsed.skipped > 0 { + return Ok(0); + } + let resources = page_resources(doc, page).unwrap_or_default(); + let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let ops = parsed.ops; + let mut out: Vec = Vec::with_capacity(ops.len()); + let (mut i, mut merged, mut k) = (0, 0, 0); + while i < ops.len() { + // The longest run of fragment groups from here with one colour space. + let mut run = Vec::new(); + let mut kind: Option<(Object, usize)> = None; + let mut j = i; + while let Some((next, m, name)) = group_at(&ops, j) { + let Some((image, width, height, cs, n)) = fragment_image(doc, &xobjects, &name) else { break }; + let [a, b, c, d, e, f] = m.0; + if b.abs() > 1e-9 || c.abs() > 1e-9 || !(a > 0.0 && d > 0.0) { + break; + } + match &kind { + Some((k_cs, k_n)) if *k_cs != cs || *k_n != n => break, + None => kind = Some((cs, n)), + _ => {} + } + run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height }); + j = next; + } + // Not one picture as a whole: the rows drawn one under the other from the start may be. + if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { + run = drawn_in_sequence(&ops, i, &xobjects, doc); + j = run_end(&ops, i, run.len()); + } + let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { + // Not a run: keep this op and look again from the next one. + if let Some(op) = ops.get(i) { + out.push(op.clone()); + } + i += 1; + continue; + }; + if !progress(Stage::Fragments) { + return Err(OptimizeError::Cancelled); + } + let Some((px, w, h)) = merge(doc, &run, n) else { + out.extend(ops.get(i..j).unwrap_or_default().iter().cloned()); + i = j; + continue; + }; + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(i64::from(w))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(h))); + dict.set(b"ColorSpace".to_vec(), cs); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let image = doc.add(Object::Stream(Stream::flate(dict, &px))); + let name = fresh_name(&xobjects, &mut k); + xobjects.set(name.clone(), Object::Ref(image)); + add_xobject(doc, page, &name, image)?; + let (Some(first), Some(last)) = (run.first(), run.last()) else { break }; + let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]]; + let num = Object::Real; + out.push(Op::new("q", Vec::new())); + out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)])); + out.push(Op::new("Do", vec![Object::Name(name)])); + out.push(Op::new("Q", Vec::new())); + merged += run.len(); + report.merged_images += 1; + i = j; + } + if merged > 0 { + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &serialize_ops(&out)))); + doc.update_dict(page, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?; + } + Ok(merged) +} + +pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<(), OptimizeError> { + for p in pages { + report.fragments_merged += merge_page(doc, *p, report, progress)?; + } + Ok(()) +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8645c82bf3..e4976a81b6 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -11,7 +11,7 @@ use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. -fn page_resources(doc: &Document, page: ObjRef) -> Option { +pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { let mut node = Some(page); for _ in 0..64 { let d = doc.get(node?).as_dict()?.clone(); @@ -23,7 +23,7 @@ fn page_resources(doc: &Document, page: ObjRef) -> Option { None } -fn content_bytes(doc: &Document, contents: &Object) -> Vec { +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { match &*doc.resolve(contents) { Object::Stream(s) => s.decoded().unwrap_or_default(), Object::Array(a) => a @@ -111,7 +111,7 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap Option { +pub(crate) fn components(doc: &Document, cs: &Object) -> Option { let cs = doc.resolve(cs); match &*cs { Object::Name(n) => match n.as_slice() { @@ -179,7 +179,7 @@ fn is_dct(s: &Stream) -> Option { } /// 8-bit samples of an image (`n` components), decoded. -fn pixels(s: &Stream, n: usize) -> Option> { +pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); if is_dct(s)? { let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; @@ -192,7 +192,7 @@ fn pixels(s: &Stream, n: usize) -> Option> { } } -fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { +pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { use image::imageops::{FilterType, resize}; Some(match n { 1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), @@ -208,6 +208,13 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { Some(out) } +/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes and no decode array that +/// changes anything. `None` when the colour space isn't grey or RGB. +pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { + let n = components(doc, s.dict.get(b"ColorSpace")?)?; + Some(is_dct(s).is_some() && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n))) +} + /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes /// nothing. Scanners write it on page images (#490). fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 7cfcb09c14..c8b9b67690 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -1,6 +1,9 @@ //! The PDF Optimizer (execution plan M11.1; Acrobat: Reduce File Size and Optimize PDF ▸ //! Advanced optimization). //! +//! - **Merge image fragments:** a page picture some PDF printers draw as thousands of images one +//! pixel row high is put back together into one image first (see [`fragments`]), so the image +//! settings can work on it. //! - **Images:** each image's effective resolution is measured where pages draw it (the //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. @@ -20,6 +23,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +pub mod fragments; mod images; mod links; mod unused; @@ -45,6 +49,8 @@ pub enum OptimizeError { pub enum Stage { /// About to process image `done` (0-based) of `total`; `done == total` once all are done. Images { done: usize, total: usize }, + /// Merging the fragments of a page picture into one image (only when a page has some). + Fragments, /// Discarding objects, cleaning up links and compressing unencoded streams. CleanUp, } @@ -73,12 +79,18 @@ pub struct ImageSettings { } impl ImageSettings { - /// Reduce File Size: bicubic to 150 ppi above 225 ppi, JPEG medium quality. + /// Reduce File Size and the Standard preset: bicubic to 150 ppi above 225 ppi, JPEG medium + /// quality. pub const REDUCE: ImageSettings = ImageSettings { downsample: true, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Jpeg(60) }; + /// The Mobile preset: bicubic to 96 ppi above 144 ppi, JPEG high quality. Pages read on a + /// screen; text in scans stays legible. + pub const MOBILE: ImageSettings = ImageSettings { downsample: true, target_ppi: 96.0, above_ppi: 144.0, compression: Compression::Jpeg(80) }; } #[derive(Clone, Debug, PartialEq)] pub struct Settings { + /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). + pub merge_fragments: bool, pub color: ImageSettings, pub gray: ImageSettings, pub discard_thumbnails: bool, @@ -98,6 +110,7 @@ impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, discard_thumbnails: true, @@ -111,9 +124,49 @@ impl Default for Settings { } } +/// The optimizer's ready-made settings, as in Acrobat's Settings menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Preset { + /// Reduce File Size's choices ([`Settings::default`]). + Standard, + /// Smaller files for reading on a phone or screen: images to 96 ppi above 144, JPEG high. + Mobile, +} + +impl Preset { + pub const ALL: [Preset; 2] = [Preset::Standard, Preset::Mobile]; + + /// The name tools use. + pub fn name(self) -> &'static str { + match self { + Preset::Standard => "standard", + Preset::Mobile => "mobile", + } + } + + pub fn from_name(name: &str) -> Option { + Preset::ALL.into_iter().find(|p| p.name() == name) + } + + pub fn settings(self) -> Settings { + match self { + Preset::Standard => Settings::default(), + Preset::Mobile => Settings { color: ImageSettings::MOBILE, gray: ImageSettings::MOBILE, ..Settings::default() }, + } + } + + /// The preset `settings` are, if they are one. + pub fn of(settings: &Settings) -> Option { + Preset::ALL.into_iter().find(|p| p.settings() == *settings) + } +} + /// What an optimization did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Report { + /// Images merged from fragments, and how many fragments went into them. + pub merged_images: usize, + pub fragments_merged: usize, pub images: usize, pub images_resampled: usize, pub images_recompressed: usize, @@ -144,6 +197,9 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; + if settings.merge_fragments { + fragments::run(doc, &pages, &mut report, progress)?; + } images::run(doc, &pages, settings, &mut report, progress)?; if !progress(Stage::CleanUp) { return Err(OptimizeError::Cancelled); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index ac675a3903..4058a026dd 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -687,3 +687,137 @@ fn a_second_run_leaves_the_stripped_stream_alone() { assert_eq!(r.streams_compressed, 0); assert_eq!(stream(&doc, c), once); } + +/// A grey image `w` px wide and one row high, every pixel `v`; JPEG or Flate. +fn strip(doc: &mut Document, w: u32, v: u8, jpeg: bool) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(1)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let px = vec![v; w as usize]; + let s = if jpeg { + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode(&px, w, 1, image::ExtendedColorType::L8).unwrap(); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Stream::from_raw(d, out) + } else { + Stream::flate(d, &px) + }; + doc.add(Object::Stream(s)) +} + +/// A page drawn the way some PDF printers write a page picture: `rows` images one pixel row +/// high, 1000 units wide, scaled into a 24 pt square (so 600 ppi across at 200 px). Row `r` +/// from the top is grey `r`; `edit` changes the content of each row. +fn printed_page(rows: usize, jpeg: impl Fn(usize) -> bool, edit: impl Fn(usize, String) -> String) -> (Document, Vec) { + let mut doc = Document::new_empty(); + let mut names = Vec::new(); + let mut refs = Vec::new(); + let mut content = String::from("q 0.024 0 0 0.024 0 0 cm\n"); + for r in 0..rows { + let img = strip(&mut doc, 200, r as u8, jpeg(r)); + refs.push(img); + let y = (rows - 1 - r) as f64; + content += &edit(r, format!("q 1000 0 0 1 0 {y} cm\n/R{r} Do\nQ\n")); + names.push(format!("R{r}")); + } + content += "Q\n"; + let xo: Vec<(&str, ObjRef)> = names.iter().map(String::as_str).zip(refs.iter().copied()).collect(); + let p = page(&mut doc, &xo, &content); + // No thumbnail: it would be the old content stream, which still names the strips. + doc.update_dict(p, |d| { + d.remove(b"Thumb"); + }) + .unwrap(); + (doc, refs) +} + +/// The images the first page's content stream draws, by name, with their streams. +fn drawn_images(doc: &Document) -> Vec { + let pages = pdfcraft_annot::page_refs(doc).unwrap(); + let p = doc.get(pages[0]).as_dict().cloned().unwrap(); + let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let c = match &*doc.resolve(p.get(b"Contents").unwrap()) { + Object::Stream(s) => s.decoded().unwrap(), + _ => panic!("contents"), + }; + pdfcraft_content::parse(&c) + .ops + .iter() + .filter(|o| o.is("Do")) + .map(|o| match &*doc.resolve(xo.get(o.name(0).unwrap()).unwrap()) { + Object::Stream(s) => s.clone(), + _ => panic!("image"), + }) + .collect() +} + +#[test] +fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() { + // 120 strips, every other one JPEG and half as wide (as one PDF printer writes them). + let (mut doc, _) = printed_page(120, |r| r % 2 == 1, |_, s| s); + let keep = Settings { + gray: ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }, + ..Settings::default() + }; + let r = optimize(&mut doc, &keep).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (1, 120), "{r:?}"); + let drawn = drawn_images(&doc); + assert_eq!(drawn.len(), 1, "one image instead of 120"); + let img = &drawn[0]; + assert_eq!((img.dict.int(b"Width"), img.dict.int(b"Height")), (Some(200), Some(120)), "the finest resolution both ways"); + let px = img.decoded().unwrap(); + // Each row is its strip's grey, top row first; JPEG rows within a few levels. + for (row, chunk) in px.chunks(200).enumerate() { + assert!(chunk.iter().all(|&v| (v as i32 - row as i32).abs() <= 3), "row {row}: {:?}", &chunk[..4]); + } + assert_eq!(r.unused_xobjects, 120, "the strips are dropped"); +} + +#[test] +fn the_mobile_preset_makes_a_printed_page_small() { + use pdfcraft_optimize::Preset; + let (doc, _) = printed_page(200, |_| true, |_, s| s); + let bytes = |d: &Document| write_full(d, &SaveOptions::default()).unwrap().len(); + let before = bytes(&doc); + let mut mobile = doc.clone(); + let r = optimize(&mut mobile, &Preset::Mobile.settings()).unwrap(); + assert_eq!((r.merged_images, r.images_resampled), (1, 1), "{r:?}"); + let img = &drawn_images(&mobile)[0]; + // 24 pt = 1/3 in at 96 ppi. + assert_eq!((img.dict.int(b"Width"), img.dict.int(b"Height")), (Some(32), Some(32))); + assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..])); + assert!(bytes(&mobile) * 10 < before, "{} → {}", before, bytes(&mobile)); + assert_eq!(Preset::of(&Preset::Mobile.settings()), Some(Preset::Mobile)); + assert_eq!(Preset::from_name("mobile"), Some(Preset::Mobile)); +} + +#[test] +fn images_that_do_not_tile_a_picture_are_left_alone() { + let unmerged = |doc: &mut Document, settings: &Settings| { + let r = optimize(doc, settings).unwrap(); + assert_eq!(r.merged_images, 0, "{r:?}"); + }; + let all = Settings::default(); + // A gap between two rows. + let (mut gap, _) = + printed_page(40, |_| false, |r, s| if r < 20 { s.replace(&format!(" 0 {} cm", 39 - r), &format!(" 0 {} cm", 41 - r)) } else { s }); + let r = optimize(&mut gap, &all).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (2, 40), "one picture each side of the gap: {r:?}"); + // Rotated rows. + let (mut turned, _) = printed_page(40, |_| false, |_, s| s.replace("q 1000 0 0 1", "q 0 1 -1000 0")); + unmerged(&mut turned, &all); + // Something else drawn between the rows. + let (mut between, _) = printed_page(40, |_| false, |r, s| if r == 10 { format!("0 0 10 10 re f\n{s}") } else { s }); + let r = optimize(&mut between, &all).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (2, 40), "two runs, each side of the fill: {r:?}"); + // Too few rows, and the option off. + let (mut few, _) = printed_page(3, |_| false, |_, s| s); + unmerged(&mut few, &all); + let (mut off, _) = printed_page(40, |_| false, |_, s| s); + unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() }); +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index a0869d3e0b..c67df55718 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2394,3 +2394,7 @@ animation effects Off معطّلة Word counting stopped unexpectedly. توقف عد الكلمات بشكل غير متوقع. Word count… عدد الكلمات… @id file.word_count عدد الكلمات… + Standard قياسي + Mobile الهاتف المحمول + Merge image fragments دمج أجزاء الصور + Optimizing… merging image fragments جارٍ التحسين… دمج أجزاء الصور diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1d2ea72bd4..84f2d6eae8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2336,3 +2336,7 @@ animation effects Off Изключени Word counting stopped unexpectedly. Преброяването на думи спря неочаквано. Word count… Брой думи… @id file.word_count Брой думи… + Standard Стандартни + Mobile Мобилни устройства + Merge image fragments Обединяване на фрагменти от изображения + Optimizing… merging image fragments Оптимизиране… обединяване на фрагменти от изображения diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4431dfc52c..3e5e3680c8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2447,3 +2447,7 @@ animation effects Off Vypnuto Word counting stopped unexpectedly. Počítání slov se neočekávaně zastavilo. Word count… Počet slov… @id file.word_count Počet slov… + Standard Standardní + Mobile Mobilní zařízení + Merge image fragments Sloučit fragmenty obrázků + Optimizing… merging image fragments Optimalizace… slučování fragmentů obrázků diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9ea54ff507..d21a24d6b0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2374,3 +2374,7 @@ animation effects Off Aus Some content could not be classified safely and was kept in the count. Einige Inhalte konnten nicht sicher zugeordnet werden und wurden mitgezählt. Counting words… {n} pages read Wörter werden gezählt… {n} Seiten gelesen Word counting stopped unexpectedly. Die Wortzählung wurde unerwartet beendet. + Standard Standard + Mobile Mobilgerät + Merge image fragments Bildfragmente zusammenführen + Optimizing… merging image fragments Optimieren… Bildfragmente werden zusammengeführt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 227a06698a..c9680ed94a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2383,3 +2383,7 @@ animation effects Off Ανενεργά Word counting stopped unexpectedly. Η καταμέτρηση λέξεων σταμάτησε απροσδόκητα. Word count… Καταμέτρηση λέξεων… @id file.word_count Καταμέτρηση λέξεων… + Standard Τυπικές + Mobile Κινητό + Merge image fragments Συγχώνευση τμημάτων εικόνας + Optimizing… merging image fragments Βελτιστοποίηση… συγχώνευση τμημάτων εικόνας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 01dc5853cd..8c63c182af 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2503,3 +2503,7 @@ animation effects Off Desactivados Word counting stopped unexpectedly. El recuento de palabras se detuvo inesperadamente. Word count… Recuento de palabras… @id file.word_count Recuento de palabras… + Standard Estándar + Mobile Móvil + Merge image fragments Combinar fragmentos de imagen + Optimizing… merging image fragments Optimizando… combinando fragmentos de imagen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7ae7d14d10..3d6bcce62f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2373,3 +2373,7 @@ animation effects Off Désactivés Word counting stopped unexpectedly. Le comptage des mots s’est arrêté de manière inattendue. Word count… Nombre de mots… @id file.word_count Nombre de mots… + Standard Standard + Mobile Mobile + Merge image fragments Fusionner les fragments d’image + Optimizing… merging image fragments Optimisation… fusion des fragments d’image diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 254214f4d4..1961246f0e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2372,3 +2372,7 @@ animation effects Off Ki Word counting stopped unexpectedly. A szavak számlálása váratlanul leállt. Word count… Szavak száma… @id file.word_count Szavak száma… + Standard Normál + Mobile Mobil + Merge image fragments Képtöredékek egyesítése + Optimizing… merging image fragments Optimalizálás… képtöredékek egyesítése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 47b32de762..bfed12bf07 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2382,3 +2382,7 @@ animation effects Off Disattivati Word counting stopped unexpectedly. Il conteggio delle parole si è interrotto in modo imprevisto. Word count… Conteggio parole… @id file.word_count Conteggio parole… + Standard Standard + Mobile Cellulare + Merge image fragments Unisci frammenti di immagine + Optimizing… merging image fragments Ottimizzazione… unione dei frammenti di immagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 91782e93ca..2ac86df72d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2373,3 +2373,7 @@ animation effects Off オフ Word counting stopped unexpectedly. 単語の集計が予期せず停止しました。 Word count… 文字数と単語数… @id file.word_count 文字数と単語数… + Standard 標準 + Mobile モバイル + Merge image fragments 画像の断片を結合 + Optimizing… merging image fragments 最適化中… 画像の断片を結合しています diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index ec9d2e5c4a..443b756a9a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2371,3 +2371,7 @@ animation effects Off Өшірулі Word counting stopped unexpectedly. Сөздерді санау күтпеген жерден тоқтады. Word count… Сөздер саны… @id file.word_count Сөздер саны… + Standard Стандартты + Mobile Мобильді құрылғы + Merge image fragments Сурет фрагменттерін біріктіру + Optimizing… merging image fragments Оңтайландыру… сурет фрагменттерін біріктіру diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 08e3b3d422..a658a2c9a3 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2617,3 +2617,7 @@ key Space Tarpas Word counting stopped unexpectedly. Žodžių skaičiavimas netikėtai sustojo. Word count… Žodžių skaičius… @id file.word_count Žodžių skaičius… + Standard Standartinis + Mobile Mobilusis + Merge image fragments Sujungti vaizdo fragmentus + Optimizing… merging image fragments Optimizuojama… jungiami vaizdo fragmentai diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4d777c46ee..1824e17424 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2353,3 +2353,7 @@ animation effects Off Wyłączone Word counting stopped unexpectedly. Liczenie słów zostało nieoczekiwanie przerwane. Word count… Liczba słów… @id file.word_count Liczba słów… + Standard Standardowe + Mobile Urządzenie mobilne + Merge image fragments Scal fragmenty obrazów + Optimizing… merging image fragments Optymalizacja… scalanie fragmentów obrazów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 61f6d67b12..a6461661c1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2387,3 +2387,7 @@ animation effects Off Desativados Word counting stopped unexpectedly. A contagem de palavras parou inesperadamente. Word count… Contagem de palavras… @id file.word_count Contagem de palavras… + Standard Padrão + Mobile Celular + Merge image fragments Mesclar fragmentos de imagem + Optimizing… merging image fragments Otimizando… mesclando fragmentos de imagem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 10f02a5532..299590f466 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2286,3 +2286,7 @@ animation effects Off Выкл Word counting stopped unexpectedly. Подсчёт слов неожиданно остановился. Word count… Количество слов… @id file.word_count Количество слов… + Standard Стандартные + Mobile Мобильные устройства + Merge image fragments Объединять фрагменты изображений + Optimizing… merging image fragments Оптимизация… объединение фрагментов изображений diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 9de0d84a4a..742f77046f 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2378,3 +2378,7 @@ animation effects Off Izklopljeni Word counting stopped unexpectedly. Štetje besed se je nepričakovano ustavilo. Word count… Število besed… @id file.word_count Število besed… + Standard Standardno + Mobile Mobilna naprava + Merge image fragments Združi delce slik + Optimizing… merging image fragments Optimiziranje… združevanje delcev slik diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 4f91019090..0079cd69ab 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2286,3 +2286,7 @@ animation effects Off ఆఫ్ Word counting stopped unexpectedly. పదాల లెక్క అనుకోకుండా ఆగిపోయింది. Word count… పదాల లెక్క… @id file.word_count పదాల లెక్క… + Standard ప్రామాణికం + Mobile మొబైల్ + Merge image fragments చిత్ర శకలాలను విలీనం చేయి + Optimizing… merging image fragments ఆప్టిమైజ్ చేస్తోంది… చిత్ర శకలాలను విలీనం చేస్తోంది diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6283ad5819..320db7fb26 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2369,3 +2369,7 @@ animation effects Off Kapalı Word counting stopped unexpectedly. Sözcük sayımı beklenmedik şekilde durdu. Word count… Sözcük sayısı… @id file.word_count Sözcük sayısı… + Standard Standart + Mobile Mobil + Merge image fragments Görüntü parçalarını birleştir + Optimizing… merging image fragments İyileştiriliyor… görüntü parçaları birleştiriliyor diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 86944c469e..abb939813e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2395,3 +2395,7 @@ animation effects Off Вимкнено Word counting stopped unexpectedly. Підрахунок слів несподівано зупинився. Word count… Кількість слів… @id file.word_count Кількість слів… + Standard Стандартні + Mobile Мобільні пристрої + Merge image fragments Об’єднувати фрагменти зображень + Optimizing… merging image fragments Оптимізація… об’єднання фрагментів зображень diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9cd784dcae..931a422c31 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2337,3 +2337,7 @@ animation effects Off 关 Word counting stopped unexpectedly. 字词统计意外停止。 Word count… 字词统计… @id file.word_count 字词统计… + Standard 标准 + Mobile 移动设备 + Merge image fragments 合并图像碎片 + Optimizing… merging image fragments 正在优化… 正在合并图像碎片 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3a3f5ad6d1..c6e9f368da 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2327,3 +2327,7 @@ animation effects Off 關 Word counting stopped unexpectedly. 字詞統計意外停止。 Word count… 字詞統計… @id file.word_count 字詞統計… + Standard 標準 + Mobile 行動裝置 + Merge image fragments 合併影像片段 + Optimizing… merging image fragments 正在最佳化… 正在合併影像片段 diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index a35f029774..c9ecfb0ccb 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; -use pdfcraft_engine::optimize::{Compression, ImageSettings, QUALITIES, Settings}; +use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -78,6 +78,7 @@ pub struct OptimizeRun { fn stage_label(stage: OptimizeStage) -> String { match stage { OptimizeStage::Discarding => tl!("Optimizing… removing user data").to_string(), + OptimizeStage::Fragments => tl!("Optimizing… merging image fragments").to_string(), OptimizeStage::Images { done, total } => crate::i18n::fmt( tl!("Optimizing… image {d} of {t}"), &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], @@ -157,6 +158,22 @@ fn discard_box(ui: &mut egui::Ui, list: &mut Vec, h: Hidden, label: &str pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (bool, bool) { ui.label(egui::RichText::new(tl!("PDF Optimizer")).font(theme::semibold(18.0))); ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(tl!("Settings")); + let name = |p: Preset| match p { + Preset::Standard => tl!("Standard"), + Preset::Mobile => tl!("Mobile"), + }; + let current = Preset::of(&d.settings); + egui::ComboBox::from_id_salt("optimize-preset").selected_text(current.map_or(tl!("Custom"), name)).show_ui(ui, |ui| { + for p in Preset::ALL { + if ui.selectable_label(current == Some(p), name(p)).clicked() { + d.settings = p.settings(); + } + } + }); + }); + ui.add_space(6.0); ui.horizontal_wrapped(|ui| { for (tab, label) in [ (OptimizeTab::Images, tl!("Images")), @@ -174,6 +191,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo let s = &mut d.settings; match d.tab { OptimizeTab::Images => { + ui.add(widgets::checkbox(&mut s.merge_fragments, tl!("Merge image fragments"))); image_row(ui, "color", "Color Images", &mut s.color); image_row(ui, "gray", "Grayscale Images", &mut s.gray); ui.label( diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index 539ebb1e69..ce2a5f37ed 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -212,6 +212,34 @@ fn the_pdf_optimizer_dialog_saves_an_optimized_copy() { assert_eq!(h.state().dialog, None); } +#[test] +fn the_pdf_optimizer_dialog_picks_a_preset() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use pdfcraft_engine::optimize::Preset; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"lorem ipsum".to_vec()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("optimize.advanced")); + h.run_steps(2); + assert_eq!(Preset::of(&h.state().optimize_draft.settings), Some(Preset::Standard)); + h.get_by_label("Merge image fragments"); + h.get_by_value("Standard").click(); + h.run_steps(4); + h.get_by_label("Mobile").click(); + h.run_steps(2); + assert_eq!(h.state().optimize_draft.settings, Preset::Mobile.settings(), "Mobile chosen"); + // Changing an option makes the settings custom. + h.get_by_label("Merge image fragments").click(); + h.run_steps(2); + assert!(!h.state().optimize_draft.settings.merge_fragments); + h.get_by_value("Custom"); +} + fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec)> { let pdf = app.session.create_from_text("a", "from a pdf").unwrap(); vec![ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c9f302cf1a..41ab3224ae 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7225,7 +7225,7 @@ status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" @@ -7248,8 +7248,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"] -notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" [[feature]] id = "optimize.downsample-images" @@ -7260,8 +7260,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn; monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" [[feature]] id = "optimize.recompress-images" From b18639c242339be913989ef8b6d6900d569b7c8b Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:25:42 +0500 Subject: [PATCH 046/207] M7: rewrap Chinese and Japanese text between characters (#1827) Chinese and Japanese are written without spaces between words, so wrapping only at spaces left a paragraph of them as a single overlong row. A row may now also end between two ideographs or kana (UAX #14 class ID), except before closing punctuation, closing brackets, small kana and similar marks, or after opening brackets and currency signs (JIS X 4051 line-start and line-end rules). Latin words inside such text stay whole; Korean and Thai still break only at spaces. Both the plain and the styled rewrite paths use the same rule. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/edit/src/lib.rs | 1 + crates/edit/src/linebreak.rs | 124 ++++++++++++++++++++++++++++++++ crates/edit/src/tests.rs | 22 ++++++ crates/edit/src/text.rs | 32 +++++++-- crates/edit/src/text_regions.rs | 6 +- docs/editing-existing-text.md | 7 ++ 6 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 crates/edit/src/linebreak.rs diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 336e948ae9..0fb7e3eeea 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -633,6 +633,7 @@ pub mod images; pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; pub mod bidi; pub mod indic; +mod linebreak; pub mod text; pub use text::{ BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, diff --git a/crates/edit/src/linebreak.rs b/crates/edit/src/linebreak.rs new file mode 100644 index 0000000000..a4682679f8 --- /dev/null +++ b/crates/edit/src/linebreak.rs @@ -0,0 +1,124 @@ +//! Where a row of rewrapped text may end besides a space: between two characters when either is +//! written without spaces between words (Chinese and Japanese ideographs and kana), following the +//! simple cases of Unicode line breaking (UAX #14, class ID). The row must not then begin with a +//! character that closes or follows (closing brackets and quotes, commas and full stops, small +//! kana, the prolonged sound mark, iteration marks) or end with one that opens (opening brackets +//! and quotes, currency signs before an amount): the line-start and line-end prohibitions of +//! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR. +//! +//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose +//! words need a dictionary to find. + +/// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID). +fn ideographic(c: char) -> bool { + matches!(c, + '\u{2e80}'..='\u{2fdf}' // CJK radicals, Kangxi radicals + | '\u{3000}'..='\u{303f}' // CJK symbols and punctuation + | '\u{3040}'..='\u{30ff}' // hiragana, katakana + | '\u{3190}'..='\u{31ff}' // kanbun, CJK strokes, katakana phonetic extensions + | '\u{3400}'..='\u{4dbf}' // CJK extension A + | '\u{4e00}'..='\u{9fff}' // CJK unified ideographs + | '\u{f900}'..='\u{faff}' // CJK compatibility ideographs + | '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms + | '\u{ff00}'..='\u{ffef}' // half-width and full-width forms + | '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes + ) +} + +/// Characters that must not begin a row: closing brackets and quotes, and the punctuation, small +/// kana and marks that belong to the character before them (UAX #14 CL, CP, EX, IS, NS). The +/// ideographic space breaks after itself, not before (BA): it is Japanese spacing and indentation. +fn no_start(c: char) -> bool { + matches!(c, + ')' | ']' | '}' | '»' | '’' | '”' | '!' | '?' | ',' | '.' | ':' | ';' | '%' | '‰' | '°' | '℃' + | '‐' | '–' | '〜' | '‼' | '⁇' | '⁈' | '⁉' | '・' | '\u{3000}' + | '、' | '。' | '〃' | '〆' | '々' | '〻' | '〕' | '〉' | '》' | '」' | '』' | '】' | '〙' | '〗' | '〟' | '〞' + | 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ' + | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' + | '゛' | '゜' | 'ゝ' | 'ゞ' | '゠' | 'ー' | 'ヽ' | 'ヾ' + | '\u{31f0}'..='\u{31ff}' // small katakana for Ainu + | '!' | '%' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '⦆' | '~' + | '。' | '」' | '、' | '・' | 'ー' | '゙' | '゚' + | '\u{ff67}'..='\u{ff6f}' // small half-width katakana + ) +} + +/// Characters that must not end a row: opening brackets and quotes, and currency signs written +/// before an amount (UAX #14 OP, PR). +fn no_end(c: char) -> bool { + matches!( + c, + '(' | '[' + | '{' + | '«' + | '‘' + | '“' + | '$' + | '£' + | '¥' + | '€' + | '〈' + | '《' + | '「' + | '『' + | '【' + | '〔' + | '〖' + | '〘' + | '〝' + | '(' + | '[' + | '{' + | '⦅' + | '「' + | '$' + | '£' + | '¥' + ) +} + +/// Whether a row may end between `before` and `after`, neither of them a space. +pub(crate) fn between(before: char, after: char) -> bool { + (ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) +} + +/// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing. +pub(crate) fn pieces(word: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let mut prev: Option = None; + for (at, c) in word.char_indices() { + if let Some(before) = prev + && between(before, c) + { + out.extend(word.get(start..at)); + start = at; + } + prev = Some(c); + } + out.extend(word.get(start..).filter(|rest| !rest.is_empty())); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ideographs_and_kana_break_between_characters_except_around_kinsoku_marks() { + assert_eq!(pieces("日本語の文章"), ["日", "本", "語", "の", "文", "章"]); + // Closing punctuation and small kana stay with the character before them; an opening + // bracket stays with the one after it. + assert_eq!(pieces("ちょっと、「はい」。"), ["ちょっ", "と、", "「は", "い」。"]); + assert_eq!(pieces("コーヒー"), ["コー", "ヒー"]); + assert_eq!(pieces("(例)です"), ["(例)", "で", "す"]); + // Latin text inside a Japanese sentence breaks before and after it, never inside it. + assert_eq!(pieces("PDFを編集"), ["PDF", "を", "編", "集"]); + assert_eq!(pieces("価格は¥100です"), ["価", "格", "は", "¥100", "で", "す"]); + // Words of space-separated writing are never cut. + assert_eq!(pieces("wrapping"), ["wrapping"]); + assert_eq!(pieces("한국어"), ["한국어"]); + assert_eq!(pieces(""), Vec::<&str>::new()); + assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字')); + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e72af1acc1..9f99349b8c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3742,3 +3742,25 @@ fn draft_layout_matches_the_paragraph_an_applied_edit_draws() { assert_eq!(layout.source_rect, source.rect); } } + +#[test] +fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_start() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping the Japanese wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "日本語の文章は単語の間に空白がないので、どの文字の間でも行を折り返せます。ただし「句読点」や小さい「っ」は行頭に来ません。"; + // One paragraph (the single-style path), and a heading row before it (the row editor's path). + for draft in [text.to_string(), format!("見出し\n{text}")] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(120.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&reopen(&doc), 0).unwrap().remove(0); + let rows: Vec<&str> = block.editor_text.split('\n').collect(); + assert!(rows.len() > 4, "{rows:?}"); + assert_eq!(rows.concat(), draft.replace('\n', ""), "no space is added between the pieces"); + assert!(block.rect[2] - block.rect[0] <= 120.5, "{:?} {rows:?}", block.rect); + for row in &rows { + assert!(!row.starts_with(['、', '。', '」', 'っ']) && !row.ends_with('「'), "{row:?} in {rows:?}"); + } + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 07ce62ade7..8fa394194f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1652,17 +1652,24 @@ fn paragraph_separator(c: char) -> bool { c.is_whitespace() && c != '\u{3000}' } -/// Greedy word wrapping to `width` with `advance` giving a string's width. +/// Greedy word wrapping to `width` with `advance` giving a string's width. A row may also end +/// inside a word of Chinese or Japanese text ([`crate::linebreak`]); those pieces join without a space. fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { let mut out = Vec::new(); let mut line = String::new(); for word in text.split(paragraph_separator).filter(|word| !word.is_empty()) { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if !line.is_empty() && advance(&candidate) > width { - out.push(std::mem::take(&mut line)); - line = word.to_string(); - } else { - line = candidate; + for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { + let candidate = match (line.is_empty(), i) { + (true, _) => piece.to_string(), + (false, 0) => format!("{line} {piece}"), + (false, _) => format!("{line}{piece}"), + }; + if !line.is_empty() && advance(&candidate) > width { + out.push(std::mem::take(&mut line)); + line = piece.to_string(); + } else { + line = candidate; + } } } if !line.is_empty() || out.is_empty() { @@ -2260,6 +2267,17 @@ mod tests { assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); } + #[test] + fn japanese_text_wraps_between_characters_but_not_before_closing_marks() { + let width = |s: &str| s.chars().count() as f64; + assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]); + // The comma can't begin a row, so the character before it moves down with it. + assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]); + // Pieces of one word join without a space; separate words keep theirs. + assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]); + assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]); + } + #[test] fn fallback_face_moves_on_when_the_best_face_lacks_a_character() { static JPAN: &[&str] = &["Jpan"]; diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 59e1607021..aec5970884 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -571,7 +571,11 @@ pub(super) fn rewrite_styled_block( } let mut end = at + 1; if !item.0.is_whitespace() { - while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) + && !c.0.is_whitespace() + && !crate::linebreak::between(before.0, c.0) + { end += 1; } } diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 9708cf17ed..233e5ac59d 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -31,6 +31,13 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any +two characters. A row never begins with closing punctuation, a closing bracket or quote, a small +kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency +sign before an amount: the line-start and line-end rules of JIS X 4051, as in Unicode line +breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only +at spaces. + Select text within the editor before changing size, colour, font, underline or spacing to format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the same properties; invalid or overlapping ranges fail atomically. Untouched source formatting From 6aa9de485c1ac8a70763e6b45b3e5c2da792c9c8 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:25:53 +0500 Subject: [PATCH 047/207] M7: justify paragraphs whose font shows the space with another code (#1832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3). Justify was therefore turned off for a composite source font, so the paragraph came out left-aligned. And the generated Type 3 fallback, whose codes are numbered from 1, had its 32nd character widened instead of its spaces. Where word spacing doesn't reach the space, each space of a stretched row is now followed by a TJ adjustment of the same amount, and word spacing stays 0. Fonts whose space is code 32 keep using Tw as before. Co-authored-by: Claude Opus 5.5 --- crates/edit/src/tests.rs | 76 +++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 40 ++++++++++++------ docs/editing-existing-text.md | 6 +++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9f99349b8c..839ba00eec 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2171,6 +2171,33 @@ fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() { assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect); } +/// A generated Type 3 font numbers its glyphs from 1, so code 32 is a character, not the space: +/// justified rows are stretched after each space, never with word spacing. +#[test] +fn justified_text_in_the_type3_fallback_stretches_its_spaces() { + let src = "BT /F1 12 Tf 14 TL 100 700 Td (Some words to fill a row of text) Tj T* (and another one below it) Tj T* (end) Tj ET"; + let mut doc = text_page(src); + let rect = text::text_blocks(&doc, 0).unwrap()[0].rect; + // More than 32 different characters, so one of them gets code 32. + let typed = "日本語の 文章を 書きます 東京 大阪 京都 名古屋 札幌 福岡 神戸 横浜 仙台 広島 千葉 新潟 静岡 熊本 岡山 金沢"; + let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }; + let rewritten = text::rewrite_block(&mut doc, 0, 0, Some(typed), &style); + if pdfcraft_fonts::document_japanese_font().is_none() { + assert!(rewritten.is_err()); + eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR"); + return; + } + rewritten.unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "{lines:?}"); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - rect[2]).abs() < 1.0, "{:?} spans {:?}, not {rect:?}", l.text, l.rect); + } + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}"); + assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}"); +} + /// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build /// without it says so and changes nothing. Hebrew points are refused, not misdrawn. #[test] @@ -2735,6 +2762,55 @@ fn group_moves_keep_split_stream_tokens_and_shared_form_resources() { assert_eq!(page_content_bytes(&doc, 1), source_page); } +#[test] +fn justified_text_in_a_composite_font_fills_each_row_with_adjustments_after_its_spaces() { + // Two-byte codes, so word spacing (single-byte code 32 only) can't stretch the spaces. + let hex = |s: &str| s.bytes().map(|b| format!("00{b:02x}")).collect::(); + let src = format!( + "BT /F1 10 Tf 12 TL 100 700 Td <{}> Tj T* <{}> Tj T* <{}> Tj ET", + hex("One two three four five six seven"), + hex("eight nine ten eleven twelve"), + hex("and then the end of it") + ); + let mut doc = text_page(&src); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0020> <007e> <0020> endbfrange", + ))); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("SyntheticCID")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + (b"ToUnicode".to_vec(), Object::Ref(unicode)), + ( + b"DescendantFonts".to_vec(), + Object::Array(vec![Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"DW".to_vec(), Object::Int(500)), + ]))]), + ), + ]); + let p = pdfcraft_model::pages(&doc)[0].obj; + let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Dict(font))])))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let before = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let right = before.rect[2]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "{lines:?}"); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]); + } + // The source font shows the text, with its spaces, and the text reads as before, rewrapped. + let flat = |s: &str| s.replace('\n', " "); + assert_eq!(flat(&text::text_blocks(&doc, 0).unwrap()[0].editor_text), flat(&before.editor_text)); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + assert!(ops.iter().all(|o| !o.is("Tf") || o.operands.first().and_then(|n| n.as_name()) == Some(b"F1".as_slice())), "{ops:?}"); + assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}"); + assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}"); +} + #[test] fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() { let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 8fa394194f..bf310156dd 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1946,9 +1946,15 @@ fn rewrite_block_from( _ => 0.0, } }; - // Justify: word spacing (single-byte code 32 only) so each line but the last fills the width. - let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite); - let justify = align == Some(crate::added::Align::Justify) && single_byte; + // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 + // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a + // generated Type 3 font, a subset without a space) the extra space follows each space as a + // TJ adjustment instead, and code 32, which may then be a letter, gets none. + let justify = align == Some(crate::added::Align::Justify); + let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), + None => type3.is_none(), + }; let mut x = 0.0; let mut tw_set = 0.0; let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space @@ -1962,24 +1968,34 @@ fn rewrite_block_from( block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); } x = dx; + let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; if tw != tw_set { block_ops.push(Op::new("Tw", vec![n(tw)])); tw_set = tw; } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(line); - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; - block_ops.extend(source_show(metrics, &indic_line, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(&visual).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); + } else { + for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != shown_font { + block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); + shown_font = font; + } + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + if stretch > 0.0 && piece.ends_with(' ') { + block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); } } + let tw = tw + stretch; let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); } diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 233e5ac59d..50b9261c90 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -50,6 +50,12 @@ a second bidi reversal; adjacent visual glyph runs still recover their own readi Only right-to-left ActualText is kept apart this way: left-to-right spans, such as the Latin ligatures (`fi`, `fl`) Word and Chrome wrap in ActualText mid-word, join their line as before. +A justified paragraph stretches each row but its last to the paragraph's width by widening its +spaces. Word spacing (`Tw`) widens only single-byte code 32, so where the font shows a space with +another code (a composite font, the generated Type 3 fallback) each space is followed by a `TJ` +adjustment instead. Justifying a paragraph with several formats in it is not supported yet: it is +left-aligned. + For headless editing, use `text_font_files` → `text_font_import` → `text_fonts` and pass a resource to `text_edit.font_resource`. `text_edit.ranges` holds selected range formatting. `text_edit_layout` accepts resource/ranges too and measures a draft without applying it. From e017ac93aa3497b05cb0b2789782291fb6880ab8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:26:05 +0200 Subject: [PATCH 048/207] Edit image: give each flip button the icon of its own mirror axis (fixes #1834) (#1841) --- crates/ui-egui/src/content_ui.rs | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 39355e6913..86976ae4dc 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -742,6 +742,11 @@ pub(crate) enum ImageAction { Replace, } +// Lucide names a flip icon after the axis it draws, Acrobat names the flip after the direction the +// picture moves: Flip horizontal (left and right swap) shows Lucide's vertical axis. +const FLIP_HORIZONTAL_ICON: &str = "flip-vertical-2"; +const FLIP_VERTICAL_ICON: &str = "flip-horizontal-2"; + /// Edit image: rotate, flip, crop, replace (shown while an added image is selected). pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::AddedImage) -> Option { let mut out = None; @@ -763,11 +768,11 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: turn(&mut i, 3); out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } - if crate::icons::button(ui, "flip-horizontal-2", 28.0, false, tl!("Flip horizontal")).clicked() { + if crate::icons::button(ui, FLIP_HORIZONTAL_ICON, 28.0, false, tl!("Flip horizontal")).clicked() { i.flip_h = !i.flip_h; out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } - if crate::icons::button(ui, "flip-vertical-2", 28.0, false, tl!("Flip vertical")).clicked() { + if crate::icons::button(ui, FLIP_VERTICAL_ICON, 28.0, false, tl!("Flip vertical")).clicked() { i.flip_v = !i.flip_v; out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } @@ -795,3 +800,53 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: } out } + +#[cfg(test)] +mod tests { + use egui_kittest::kittest::Queryable; + + use super::*; + + /// The image a flip button leaves behind, as the panel returns it. + fn flipped(tooltip: &str) -> pdfcraft_engine::AddedImage { + let img = pdfcraft_engine::AddedImage::new([0.0, 0.0, 100.0, 50.0], pdfcraft_cos::ObjRef::new(7, 0)); + let mut fonts = false; + let mut h = egui_kittest::Harness::new_ui_state( + move |ui, out: &mut Option| { + // The app's fonts (the semibold section title) take effect from the next frame. + if !fonts { + ui.ctx().set_fonts(crate::theme::font_definitions()); + fonts = true; + return; + } + let t = Tokens::get(ui.ctx()); + if let Some(ImageAction::Update(AddedContent::Image(i))) = image_panel(ui, &t, &img) { + *out = Some(i); + } + }, + None, + ); + h.run(); + h.get_by_label(tooltip).click(); + h.run(); + h.state().clone().expect("the button updates the image") + } + + #[test] + fn flip_buttons_show_the_mirror_axis_of_the_flip_they_make() { + // #1834: Acrobat's Flip horizontal swaps left and right across a vertical axis. Lucide names + // its flip icons after the axis drawn, so that is its flip-vertical-2. + let svg = |name: &str| { + let (_, bytes) = crate::icon_data::ICONS.iter().find(|(n, _)| *n == name).expect("bundled icon"); + String::from_utf8_lossy(bytes).into_owned() + }; + let (h, v) = (svg(FLIP_HORIZONTAL_ICON), svg(FLIP_VERTICAL_ICON)); + assert!(h.contains("M12 2v2") && !h.contains("M4 12H2"), "Flip horizontal draws a vertical axis:\n{h}"); + assert!(v.contains("M4 12H2") && !v.contains("M12 2v2"), "Flip vertical draws a horizontal axis:\n{v}"); + + let i = flipped("Flip horizontal"); + assert!(i.flip_h && !i.flip_v, "Flip horizontal mirrors left and right"); + let i = flipped("Flip vertical"); + assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom"); + } +} From 9c2fc4f8e9143435ceb34d05ca2c42f1d8642c4f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:26:16 +0200 Subject: [PATCH 049/207] M10: A6 paper in the print dialog and doc_print (fixes #1838) (#1849) --- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 1 + crates/print/README.md | 2 +- crates/print/src/lib.rs | 3 ++- parity/acrobat-features.toml | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 82bb2dfbf5..e4b1445e2d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -485,7 +485,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5/A6 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0662362ba8..43705c88fa 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5661,6 +5661,7 @@ fn architectural_and_iso_papers_through_tools() { ("A2", 420.0 * mm, 594.0 * mm), ("A1", 594.0 * mm, 841.0 * mm), ("A0", 841.0 * mm, 1189.0 * mm), + ("A6", 105.0 * mm, 148.0 * mm), ("Letter", 612.0, 792.0), ("US Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), diff --git a/crates/print/README.md b/crates/print/README.md index 6a489977dd..c7d6351fa7 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -56,7 +56,7 @@ grayscale. ## Architectural sheets and scale -The Paper menu and `doc_print` share Letter, Legal, Tabloid, A3/A4/A5 plus +The Paper menu and `doc_print` share Letter, Legal, Tabloid, A3/A4/A5/A6 plus ARCH A/B/C/D/E1/E, ANSI C/D/E and ISO A0/A1/A2. ARCH D is 24 × 36 inches; ANSI D is 22 × 34 inches. Dimensions follow the [HP DesignJet paper-size table](https://support.hp.com/hr-en/document/bpp01676). Automation accepts short names such as `"ARCH D"` (case-insensitive) or the full menu label. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 7e6413d3cf..e45babadb5 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -237,13 +237,14 @@ impl Default for Settings { } /// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 18] = [ +pub const PAPERS: [(&str, (f64, f64)); 19] = [ ("US Letter", (612.0, 792.0)), ("US Legal", (612.0, 1008.0)), ("Tabloid", (792.0, 1224.0)), ("A3", (841.89, 1190.55)), ("A4", (595.28, 841.89)), ("A5", (419.53, 595.28)), + ("A6", (297.64, 419.53)), ("ARCH A (9 x 12 in)", (648.0, 864.0)), ("ARCH B (12 x 18 in)", (864.0, 1296.0)), ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 41ab3224ae..bfc05d1075 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7985,7 +7985,7 @@ status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"] -notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2 presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible" +notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2 and A6 presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible" [[feature]] id = "print.paper-source-by-size" From 0a054a425ce31846200de1b460d71250ec976003 Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 15 Oct 2026 08:26:28 +0200 Subject: [PATCH 050/207] M7: Add / Update / Remove menu on Header and footer, Watermark, Background (#1192) (#1857) The Update and Remove commands existed and were enabled only when the document had that kind of mark, but no menu listed them. A tool entry in the catalogue can now list further commands (ToolItem::actions); the tool panel shows a caret and opens a menu, each command enabled by its own needs. Add stays usable with no document open (it asks for a file first). Tests: the catalogue's menus name registered commands starting with the entry's own, and every edit.*.update / edit.*.remove is reachable from a tool entry. --- ROADMAP.md | 1 + crates/engine/src/catalog.rs | 56 +++++++++++++++++++++++++++++++++--- crates/ui-egui/src/panels.rs | 28 ++++++++++++++++-- 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 284e87419c..ed84b6ab4e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -458,3 +458,4 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | - **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags. +- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192). diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 991c86a9ca..afc0673be9 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -24,6 +24,11 @@ pub struct ToolItem { pub icon: &'static str, pub command: &'static str, pub availability: Availability, + /// Further commands of the same tool, shown as a drop-down beside the entry: Acrobat's + /// Watermark control offers Add, Update and Remove. The first is `command` itself. A UI + /// enables each one by its own command's needs, so Update and Remove stay greyed out until + /// the document has that kind of mark. Empty for a plain entry. + pub actions: &'static [&'static str], } #[derive(Clone, Copy, Debug)] @@ -45,7 +50,16 @@ pub struct ToolGroup { } const fn item(label: &'static str, icon: &'static str, command: &'static str, availability: Availability) -> ToolItem { - ToolItem { label, icon, command, availability } + ToolItem { label, icon, command, availability, actions: &[] } +} + +/// An entry that opens a menu of `actions` (the first one is its `command`). +const fn menu_item(label: &'static str, icon: &'static str, actions: &'static [&'static str]) -> ToolItem { + let command = match actions { + [first, ..] => *first, + [] => "", + }; + ToolItem { label, icon, command, availability: Ready, actions } } use Availability::{Planned, Provider, Ready}; @@ -128,11 +142,11 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready), item("Text", "type", "edit.text", Ready), item("Image", "image-plus", "edit.image", Ready), - item("Header and footer", "heading", "edit.header_footer", Ready), - item("Watermark", "stamp", "edit.watermark", Ready), + menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]), + menu_item("Watermark", "stamp", &["edit.watermark", "edit.watermark.update", "edit.watermark.remove"]), item("Link", "link-2", "edit.link", Ready), item("Bates numbering", "hash", "edit.bates", Ready), - item("Background", "palette", "edit.background", Ready), + menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]), item("Attach file", "paperclip", "edit.attach", Planned("M12")), ], }, @@ -555,4 +569,38 @@ mod tests { } } } + + #[test] + fn menu_entries_list_registered_commands_starting_with_their_own() { + let mut menus = 0; + for g in TOOL_GROUPS { + for s in g.sections { + for i in s.items { + if i.actions.is_empty() { + continue; + } + menus += 1; + assert_eq!(i.actions[0], i.command, "{}: the first action is the entry's command", i.label); + for id in i.actions { + assert!(crate::commands::command(id).is_some(), "{id} is not a registered command"); + } + } + } + } + // Watermark, Header and footer, Background: Add / Update / Remove each (Acrobat's menu). + assert!(menus >= 3); + } + + #[test] + fn every_update_and_remove_command_is_reachable_from_a_tool_entry() { + let reachable: HashSet<&str> = + TOOL_GROUPS.iter().flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())).flat_map(|i| i.actions.iter().copied()).collect(); + for id in crate::commands::COMMANDS + .iter() + .map(|c| c.id) + .filter(|id| id.starts_with("edit.") && (id.ends_with(".update") || id.ends_with(".remove"))) + { + assert!(reachable.contains(id), "{id} is in no tool menu"); + } + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index dc989d660e..536a475755 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -230,8 +230,32 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static row_label(ui, tl!(item.label), theme::regular(13.0), fg, body.left_center() + vec2(34.0, 0.0), r.left() - body.left() - 42.0); ui.painter().rect_filled(r, CornerRadius::same(4), fill); ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg); - if theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command)).clicked() { - run = Some(item.command); + let resp = theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command)); + if item.actions.is_empty() { + if resp.clicked() { + run = Some(item.command); + } + } else { + // A caret before the chip says the entry opens a menu (Add / Update / Remove). + let c = r.left_center() - vec2(10.0, 0.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-3.5, -2.0), c + vec2(3.5, -2.0), c + vec2(0.0, 2.5)], + fg, + egui::Stroke::NONE, + )); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(180.0); + for (n, id) in item.actions.iter().enumerate() { + let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; + // Add stays usable with no document open: it asks for a file first. + let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec); + let label = crate::i18n::menu_label(spec.id, spec.label); + if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + run = Some(spec.id); + ui.close(); + } + } + }); } } } From e337a496312163a5b7f7741becdab4ee8424dead Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:26:51 +0800 Subject: [PATCH 051/207] fix(a11y): mark_decorative no longer panics on an unpaired ParentTree key (#1809) (#1862) clear_parent_tree walked /Nums with chunks_mut(2) and indexed pair[1], so a trailing key without a value that matched the page's StructParents panicked after the page content had already been replaced. Walk whole pairs only and leave an unpaired key as it is: it maps nothing, so there is nothing to clear. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/a11y/src/alt.rs | 11 +++++------ crates/a11y/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 21bd8448d8..71bc90a038 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -414,12 +414,11 @@ fn clear_parent_tree(doc: &mut Document, key: i64, obj: ObjRef) -> Result<(), Al let Some(pt) = tree_root(doc).and_then(|r| r.get(b"ParentTree").and_then(Object::as_ref)) else { return Ok(()) }; let Some(nums) = doc.get(pt).as_dict().and_then(|d| d.get(b"Nums").and_then(|n| n.as_array().cloned())) else { return Ok(()) }; let mut nums = nums; - for pair in nums.chunks_mut(2) { - if pair[0].as_int() == Some(key) { - match &pair[1] { - Object::Array(a) => { - pair[1] = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect()) - } + // Whole pairs only: a trailing key without a value maps nothing, so it is left as it is. + for [k, v] in nums.as_chunks_mut::<2>().0 { + if k.as_int() == Some(key) { + match &*v { + Object::Array(a) => *v = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect()), Object::Ref(r) => { if let Some(a) = doc.get(*r).as_array().cloned() { let a: Vec = a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect(); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 6f9deaeb80..6e23c5c6a0 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -607,6 +607,23 @@ fn marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrit assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)), Err(ArtifactError::NotATag(7))); } +#[test] +fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() { + // A trailing key without a value, matching the page's StructParents (#1809), and the same + // with no valid pair at all. + let tree = |nums: &str| pdf(&[format!("<< /Nums {nums} >>")], "").get(pdfcraft_cos::ObjRef::new(1, 0)).as_ref().clone(); + for (nums, cleared) in [("[0 [7 0 R 8 0 R] 0]", "[0 [null 8 0 R] 0]"), ("[0]", "[0]")] { + let mut doc = figures_doc(); + let pt = pdfcraft_cos::ObjRef::new(9, 0); + doc.set(pt, tree(nums)); + let f = figures(&doc); + mark_decorative(&mut doc, f[0].obj).unwrap(); + assert_eq!(figures(&doc).len(), 1, "{nums}"); + // The pair is cleared and the unpaired key is kept as it was. + assert_eq!(*doc.get(pt), tree(cleared), "{nums}"); + } +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } From 4d358d4fa4df894d7de6f63a0cecf16d8a1bdd6a Mon Sep 17 00:00:00 2001 From: pauliusiv123-rgb <235694617+pauliusiv123-rgb@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:27:02 +0300 Subject: [PATCH 052/207] M2: a /Rotate that isn't a quarter turn counts as 0, as the page is drawn (#1863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page::rotation rounded a /Rotate that isn't a multiple of 90 down to a quarter turn (135 became 90), while the renderer draws such a page upright. Display size and the display-to-user-space matrix (header and footer, watermarks, measuring, printing, image signatures) then disagreed with the page on screen. ISO 32000-2 §7.7.3.3 requires a multiple of 90; read any other value as 0, as the renderer does. Page import and image stamps, which read /Rotate themselves, already treat such a value as 0. Test: rotate_off_a_quarter_turn_counts_as_upright (/Rotate 135, 30, -45, 359). --- crates/model/README.md | 3 ++- crates/model/src/lib.rs | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/model/README.md b/crates/model/README.md index 06c30b0394..5872b25665 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -9,7 +9,8 @@ L3/L4 crates need: geometry, and the matrix from "display space" (points, origin at the bottom-left of the page as shown, after `/UserUnit` and `/Rotate`) to user space, so content can be placed the way the reader sees the page. `/UserUnit` is read as Acrobat reads it: the page's own entry, a number - from 1 up, at most 75,000; anything else counts as 1. + from 1 up, at most 75,000; anything else counts as 1. A `/Rotate` that isn't a multiple of 90 + counts as 0, as the renderer draws it. `view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a comment's picture upright inside its user-space `/Rect` on a turned page. diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 4e4f3f53be..211b44492b 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -37,9 +37,11 @@ impl Page { } } - /// Clockwise rotation when displayed: 0, 90, 180 or 270. + /// Clockwise rotation when displayed: 0, 90, 180 or 270. A `/Rotate` that isn't a multiple + /// of 90 (ISO 32000-2 §7.7.3.3 requires one) counts as 0, as the renderer draws that page. pub fn rotation(&self, doc: &Document) -> i64 { - self.dict.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).rem_euclid(360) / 90 * 90 + let r = self.dict.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).rem_euclid(360); + if r % 90 == 0 { r } else { 0 } } /// The size of the page's user-space unit in points (`/UserUnit`; it isn't inherited), read @@ -184,6 +186,21 @@ mod tests { } } + /// A `/Rotate` that isn't a quarter turn counts as 0, as the renderer draws the page + /// upright, so display space matches what's on screen. + #[test] + fn rotate_off_a_quarter_turn_counts_as_upright() { + for rotate in [135, 30, -45, 359] { + let d = doc(rotate); + let p = &pages(&d)[0]; + assert_eq!(p.rotation(&d), 0, "{rotate}"); + assert_eq!(p.display_size(&d), (200.0, 300.0), "{rotate}"); + assert_eq!(p.view_matrix(&d), [1.0, 0.0, 0.0, 1.0, 10.0, 20.0], "{rotate}"); + } + let d = doc(-90); + assert_eq!(pages(&d)[0].rotation(&d), 270); + } + /// Display space is in points: `/UserUnit 2` doubles it. As in Acrobat, the value isn't /// inherited, values below 1 or not numbers count as 1, and values over 75,000 as 75,000. #[test] From 81982b092b9acd6f0b2d4ffc909210e1aa10c4e3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:27:12 +0800 Subject: [PATCH 053/207] fix(cos): a nonpositive crypt-filter /Length no longer overflows (#1816) (#1864) encrypt_dict multiplied any V4 StdCF /Length up to 32 by 8, so a large negative value overflowed (a panic with overflow checks, a wrapped length without). A zero or negative length is meaningless; read it as if absent, the 128-bit default, which also fixes small negatives that used to clamp to a 40-bit key and fail to open. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/cos/src/security.rs | 6 ++++-- crates/cos/tests/encryption.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 2605bb20f9..cd0d04a144 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -34,12 +34,14 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { } let v = int(b"V", 0); // V4: the key length comes from the standard crypt filter's /Length (bytes, or bits in - // some files); /Length on the dictionary is often a stale 40. + // some files); /Length on the dictionary is often a stale 40. A length that isn't + // positive means nothing and reads as if absent. let cf_len = d .get(b"CF") .map(|c| doc.resolve(c)) .and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f)))) - .and_then(|f| f.as_dict().and_then(|f| f.int(b"Length"))); + .and_then(|f| f.as_dict().and_then(|f| f.int(b"Length"))) + .filter(|n| *n > 0); let length_bits = match (v, cf_len) { (5.., _) => 256, (4, Some(n)) if n <= 32 => n * 8, diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index cf519df67d..849dbdc840 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -278,3 +278,26 @@ fn signature_contents_stay_unencrypted() { assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}"); } } + +#[test] +fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { + // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is + // meaningless and is read as if absent (128 bits), not multiplied into an overflow (#1816). + let original = protect(Algorithm::Aes128, "", "owner", -1); + for length in [i64::MIN, i64::MIN / 8 - 1, i64::MIN / 8, -1, 0, 16, 128] { + let mut doc = Document::open(Arc::new(original.clone())).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + doc.update_dict(encrypt, |e| { + let Some(Object::Dict(cf)) = e.get(b"CF") else { panic!("no /CF") }; + let Some(Object::Dict(std_cf)) = cf.get(b"StdCF") else { panic!("no /StdCF") }; + let (mut cf, mut std_cf) = (cf.clone(), std_cf.clone()); + std_cf.set(b"Length".to_vec(), Object::Int(length)); + cf.set(b"StdCF".to_vec(), Object::Dict(std_cf)); + e.set(b"CF".to_vec(), Object::Dict(cf)); + }) + .unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let again = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(content(&again), SECRET, "/Length {length}"); + } +} From 424459f386bca519da390de14503bb36f32f59cc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:27:23 +0800 Subject: [PATCH 054/207] fix(audit): find rectangle covers closed with h or painted with b/b* (#1817) (#1865) The redaction audit turned an re rectangle into an unknown path on a redundant h, and its fill branch left out the close-fill-stroke operators b and b*, so those opaque covers over extractable text produced no finding. Keep a closed rectangle closed under h and treat b/b* like the other fills. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/audit/src/cover.rs | 5 ++++- crates/audit/src/tests.rs | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index be8afe0a85..3df666c190 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -219,10 +219,13 @@ impl<'a> Walker<'a> { b"h" => { path = match path { Path::Lines(pts, _) => Path::Lines(pts, true), + // `re` already closed its subpath, so a further `h` changes nothing. + Path::Rect(r) => Path::Rect(r), _ => Path::Other, }; } - b"f" | b"F" | b"f*" | b"B" | b"B*" => { + // `b`/`b*` close the path before filling it, which the fill does anyway. + b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect() && gs.alpha >= 1.0 { diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index be98d72842..a9397145a0 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -232,8 +232,14 @@ fn self_drawing_form_fails_the_audit() { fn rectangle_drawn_as_a_path_covers_text_like_re() { // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or // only by the fill itself), or in a scaled user space: all are faux redactions (#1510). - let covers: [&[u8]; 5] = [ + // So are `re` with a redundant `h`, and either path painted with `b`/`b*` (#1817). + let covers: [&[u8]; 10] = [ b"0 g\n100 688 220 32 re f\n", + b"0 g\n100 688 220 32 re h f\n", + b"0 g\n100 688 220 32 re b\n", + b"0 g\n100 688 220 32 re h b*\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l b\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h b*\n", b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n", b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n", b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n", @@ -253,9 +259,12 @@ fn rectangle_drawn_as_a_path_covers_text_like_re() { #[test] fn non_rectangular_path_is_not_a_cover() { // Each path's bounding box covers the text, but the shape itself does not hide it all. - let covers: [&[u8]; 5] = [ + let covers: [&[u8]; 7] = [ // A triangle. b"0 g\n100 688 m 320 688 l 320 720 l h f\n", + // A triangle painted with `b`, and a trapezoid with `b*`. + b"0 g\n100 688 m 320 688 l 320 720 l b\n", + b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l b*\n", // A triangle, then a line from its start point after `h`: not a fourth corner. b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n", // A trapezoid: four corners, one slanted side. From c14602993fdc696e90b5efc0d5efbe8a60a97691 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:27:33 +0800 Subject: [PATCH 055/207] fix(sign): an unreadable extendedKeyUsage allows no purpose (#1807) (#1866) A malformed EKU left extended_key_usage at None, which reads as "no restriction", so an issuer-signed certificate with a broken EKU could reach a Valid verdict; EKU being on the processed-critical list let a critical one through as well. Like keyUsage, a present EKU now restricts the key even when it doesn't parse: it must be a SEQUENCE whose members are all OIDs, otherwise it allows no purpose. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 --- crates/sign/src/x509.rs | 12 ++++++++---- crates/sign/tests/chain.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index e3e784baec..1a0ef58ade 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -231,11 +231,15 @@ impl Extensions { self.authority_key_id = aki.children().ok().and_then(|c| c.into_iter().find(|t| t.tag == tag::ctx(0))).map(|t| t.value.to_vec()); } } - // ExtendedKeyUsage: a SEQUENCE OF OID. + // ExtendedKeyUsage: a SEQUENCE OF OID. Like keyUsage, one that is present restricts + // the key even when it doesn't parse: an unreadable one allows no purpose at all. "2.5.29.37" => { - if let Ok(eku) = Tlv::parse_all(value.value) { - self.extended_key_usage = eku.children().ok().map(|c| c.into_iter().filter_map(|t| t.oid().ok()).collect::>()); - } + let purposes = Tlv::parse_all(value.value) + .ok() + .filter(|eku| eku.tag == tag::SEQUENCE) + .and_then(|eku| eku.children().ok()) + .and_then(|c| c.iter().map(|t| t.oid().ok()).collect::>>()); + self.extended_key_usage = Some(purposes.unwrap_or_default()); } // Authority Information Access: OCSP and CA-issuer locations. "1.3.6.1.5.5.7.1.1" => { diff --git a/crates/sign/tests/chain.rs b/crates/sign/tests/chain.rs index e8fd012748..ece529765a 100644 --- a/crates/sign/tests/chain.rs +++ b/crates/sign/tests/chain.rs @@ -298,3 +298,30 @@ fn unreadable_constraint_extensions_fail_closed() { let c = issue("Garbled Constraints", None, FOREVER, &[garbage]); assert!(c.cert.has_basic_constraints && !c.cert.is_ca && !c.cert.may_issue()); } + +#[test] +fn an_unreadable_extended_key_usage_allows_no_purpose() { + // An extendedKeyUsage that is present restricts the key even when it doesn't parse (#1807): + // truncated, critical or not; a SET instead of a SEQUENCE; or a member that isn't an OID. + // Each would otherwise read as "no restriction", or as only its readable members. + let r = root(); + let any = der::oid("2.5.29.37.0"); + let truncated = [0x30, 0x05, 0x06]; + let malformed = [ + ext("2.5.29.37", &truncated), + der::seq(&[&der::oid("2.5.29.37"), &der::octets(&truncated)]), + ext("2.5.29.37", &der::tlv(tag::SET, &any)), + ext("2.5.29.37", &der::seq(&[&der::int(1), &any])), + ]; + for (i, eku) in malformed.into_iter().enumerate() { + let signer = signer_with("Malformed EKU", &r, &[key_usage(DIGITAL_SIGNATURE), eku]); + assert_eq!(signer.cert.extended_key_usage.as_deref(), Some(&[][..]), "case {i}"); + let (status, details) = verdict(signer, vec![], &r.cert); + assert_eq!(status, Status::Unknown, "case {i}: {details:?}"); + assert!(details.iter().any(|d| d.contains("not issued for signing documents")), "case {i}: {details:?}"); + } + // The same document-signing purpose, well formed, may sign. + let (status, details) = + verdict(signer_with("Good EKU", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.5.29.37.0"])]), vec![], &r.cert); + assert_eq!(status, Status::Valid, "{details:?}"); +} From f0c877ca2379779aa21dbd587e27f9c5bf069e18 Mon Sep 17 00:00:00 2001 From: ejbarraza <72894266+ejbarraza@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:27:44 -0700 Subject: [PATCH 056/207] audit: harden finding classes with golden tests and docs (#1892) - Pin FindingKind stable IDs (covered-text, unapplied-mark) with a test and document the no-rename contract on id() - Golden tests pinning the full AuditFinding struct for both finding classes (page, kind, exact rect, covered text) - Depth-limit boundary tests: 32 nested form XObjects audit clean, 33 fail closed with TooDeep - Fix stale crate docs: m/l axis-aligned rectangles count as covers (#1510) - Document AuditFinding.rect normalization and covered_text joining Tests + docs only, no behavior change. Co-authored-by: Erick Barraza --- crates/audit/src/lib.rs | 24 +++++++---- crates/audit/src/tests.rs | 91 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index ad26c25596..23965f6c20 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -9,12 +9,13 @@ //! unreadable (WCAG contrast below 3.0). Page backgrounds, table shading and highlight //! marks are therefore not flagged. //! -//! Detection limits (documented, not hidden): only `re` rectangles closed by a fill operator -//! are considered — arbitrary filled paths, shadings and patterns are not; a rectangle drawn -//! with a translucent ExtGState (`/ca < 1`) is not flagged; annotation rectangles are compared -//! in unrotated user space, so pages with `/Rotate` may misalign; covers inside form XObjects -//! are found, but only text drawn directly in the page content is checked under them, so text -//! inside a form XObject is not audited. +//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l` +//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are +//! considered when closed by a fill operator — other filled paths, shadings and patterns are +//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation +//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign; +//! covers inside form XObjects are found, but only text drawn directly in the page content +//! is checked under them, so text inside a form XObject is not audited. //! //! The audit fails closed: any content that cannot be read (an undecodable content stream, //! malformed annotations, form XObjects nested deeper than 32 or in a cycle) is an error, never @@ -40,6 +41,11 @@ pub enum FindingKind { } impl FindingKind { + /// The stable machine-readable ID for this finding class. + /// + /// These strings are the crate's public vocabulary: they appear in CLI output and are + /// the IDs a future machine-readable format would serialize. Do not rename them; + /// add a new variant instead. pub fn id(self) -> &'static str { match self { FindingKind::CoveredText => "covered-text", @@ -54,9 +60,11 @@ pub struct AuditFinding { /// 0-based page index. pub page: usize, pub kind: FindingKind, - /// The covering rectangle (or the mark's rectangle), in page user space. + /// The covering rectangle (or the mark's rectangle), in page user space, normalized so + /// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`. pub rect: [f64; 4], - /// The extractable text the cover hides (possibly empty when nothing decodable remains). + /// The extractable text the cover hides, lines joined with single spaces and trimmed. + /// Possibly empty when nothing decodable remains under the cover. pub covered_text: String, } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index a9397145a0..ff20f5e849 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use pdfcraft_cos::Document; -use crate::{FindingKind, audit_redactions}; +use crate::{AuditFinding, FindingKind, audit_redactions}; /// Minimal PDF assembler: objects are numbered 1..=n in order. struct Builder { @@ -281,3 +281,92 @@ fn non_rectangular_path_is_not_a_cover() { assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover)); } } + +#[test] +fn finding_kind_ids_are_stable() { + // The string IDs are the crate's public vocabulary (CLI output, future machine-readable + // formats). They must not change; add a variant instead of renaming. + assert_eq!(FindingKind::CoveredText.id(), "covered-text"); + assert_eq!(FindingKind::UnappliedMark.id(), "unapplied-mark"); +} + +#[test] +fn covered_text_finding_is_fully_determined() { + // Golden test: the whole finding, not just its presence, is pinned. + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!( + findings, + vec![AuditFinding { + page: 0, + kind: FindingKind::CoveredText, + rect: [100.0, 688.0, 320.0, 720.0], + covered_text: "SECRET DATA 123".to_string(), + }] + ); +} + +#[test] +fn unapplied_mark_finding_is_fully_determined() { + // Golden test for the second finding class, with its exact mark rectangle. + let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n"; + let page_extra = "/Annots [6 0 R]"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>")); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!( + findings, + vec![AuditFinding { page: 0, kind: FindingKind::UnappliedMark, rect: [95.0, 685.0, 305.0, 725.0], covered_text: "DO NOT SHARE".to_string() }] + ); +} + +/// A page whose content draws a chain of `n` nested form XObjects. The innermost form draws +/// nothing, so a successful audit reports no findings. +fn nested_forms(n: usize) -> Vec { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + // Object 3: page; object 4: content; objects 5..5+n: the form chain. + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /XObject << /F1 5 0 R >> >> >>"); + b.add_stream(b"/F1 Do"); + for i in 1..=n { + let obj = 4 + i; + if i < n { + let next = obj + 1; + let name = format!("/F{}", i + 1); + b.add_stream_with( + format!("/{name} Do", name = &name[1..]).as_bytes(), + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /F{i} {next} 0 R >> >>", + i = i + 1, + next = next + ), + ); + } else { + b.add_stream_with(b"q Q", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]"); + } + debug_assert_eq!(b.objects.len(), obj); + } + b.build() +} + +#[test] +fn forms_nested_at_depth_limit_audit_clean() { + // 32 nested forms is the deepest walk the audit follows: it must succeed and report + // the page clean rather than fail. + let findings = audit_redactions(&open(nested_forms(32))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +#[test] +fn forms_nested_past_depth_limit_fail_closed() { + // The 33rd nesting level cannot be followed: the audit fails rather than report the + // page clean with a possible cover left unseen. + let result = audit_redactions(&open(nested_forms(33))); + assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); +} From 56ef1f1c4e8ce42f583dd93b7d662600c76ca987 Mon Sep 17 00:00:00 2001 From: Alex <270190108+CouchRiv@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:27:55 -0400 Subject: [PATCH 057/207] fix(audit): an applied redaction at the end of a line is not a faux redaction (#1898) * fix(audit): an applied redaction at the end of a line is not a faux redaction After Apply Redactions removes text at the end of a line, the line keeps a TJ adjustment where the text was, and the box of the line still spans it. The audit compared the redaction box with that box: at the end of a line it covered more than 40% of it and the words left beside it were reported as covered text, on a correctly redacted file. In the middle of a line the gap was usually too small to cross the threshold. Text lines now also give the boxes of the words they draw (`ink`: glyphs, without spaces and TJ adjustments), and the audit measures how much of those words a cover hides. A real cover over text that is still there is found as before. doc_audit_redactions also reports its rectangles in top-left-origin points like comment_list and doc_info; they were in PDF user space. Co-Authored-By: Claude Opus 5.5 * fix(audit): leave the rectangles of doc_audit_redactions in user space here Reporting doc_audit_redactions rectangles in top-left-origin points is a separate subject from the false alarm this branch fixes, and on its own it made the tool and `pdfcraft-cli audit` disagree on the same file: the CLI still prints PDF user space. Take it out of this change; the tool and the CLI can move together in their own change. Co-Authored-By: Claude Opus 5.5 * fix(edit): word boxes span whole words when glyphs run backwards With a negative font size or a negative horizontal scale (Tz), each glyph advances backwards along the text matrix, and the box of a word kept only its first glyph: the end of the word was compared with a start that had already moved past it. A word box is now the union of its glyphs whichever way they go, and a TJ adjustment ends a word past 0.2 em of the scaled size, whatever its sign. Co-Authored-By: Claude Opus 5.5 * fix(audit): a cover over a short value after a long label is still found Measuring a cover on the words of a line alone missed faux redactions main found: a long label followed by a short value hidden under the box is less than 40% of the line's words. A cover now also counts, as before, when it hides 40% of the line's box and at least half of one word still drawn; a cover over the gap an applied redaction left, with no word under it, still does not. The audit README states both rules. Co-Authored-By: Claude Opus 5.5 * fix(audit): retain partial-value cover detections Co-Authored-By: OpenAI Codex --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: OpenAI Codex --- crates/audit/README.md | 9 ++- crates/audit/src/lib.rs | 47 +++++++++------ crates/audit/src/tests.rs | 87 +++++++++++++++++++++++++++ crates/automation/tests/automation.rs | 25 +++++++- crates/edit/src/tests.rs | 22 +++++++ crates/edit/src/text.rs | 46 +++++++++++++- parity/acrobat-features.toml | 4 +- 7 files changed, 218 insertions(+), 22 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index c8a6a3249f..f904680153 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -16,6 +16,11 @@ It reports two kinds of finding: A properly applied redaction removes the text, so it never flags. Page backgrounds, table shading and highlights don't either: they're painted before the text, or light against it. +A rectangle covers a text line when it hides at least 40% of the area of the words the line +draws, or at least 40% of the line's box together with a positive-area intersection with one +of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but +holds no word, so a redaction box over it alone does not count. + Limits, by design and documented rather than hidden: - only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose @@ -23,7 +28,9 @@ Limits, by design and documented rather than hidden: translucent one (`/ca` below 1) is not flagged; - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; -- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign. +- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign; +- a cover that hides less than 40% of a line's box and less than 40% of its words (a short + value in the middle of a long line) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 23965f6c20..204d237b3d 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -84,7 +84,8 @@ pub enum AuditError { Edit(#[from] pdfcraft_edit::EditError), } -/// Fraction of a text line's area that must lie under a cover to count as hidden. +/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a +/// cover to count as hidden (see [`covers`]). const COVER_FRACTION: f64 = 0.4; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; @@ -114,7 +115,7 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> for annot in annots(doc, &page.dict)? { let mut covered = String::new(); for line in &lines { - if covers(&annot, &line.rect) { + if covers(&annot, line) { if !covered.is_empty() { covered.push(' '); } @@ -134,23 +135,35 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> /// make the text unreadable. Light covers (table shading, highlights) over dark text fail the /// contrast check and are not flagged. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } -/// Does `cover` hide a substantial part of `line`? -fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool { - let ix0 = cover[0].max(line[0]); - let iy0 = cover[1].max(line[1]); - let ix1 = cover[2].min(line[2]); - let iy1 = cover[3].min(line[3]); - if ix1 <= ix0 || iy1 <= iy0 { - return false; - } - let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0); - if line_area <= 0.0 { - return false; - } - (ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION +/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough: +/// +/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words, +/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where +/// the text was), and the box still spans it, so a redaction box at the end of a line used to +/// cover most of the box and flag the words left beside it. +/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink +/// of a word still drawn. This keeps a cover over a short value after a long label +/// flagged, while a cover over the gap alone, with no word under it, stops counting. +/// +/// A line without word boxes is measured on its box alone. +fn covers(cover: &[f64; 4], line: &TextLine) -> bool { + let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]); + let fraction = |boxes: &[[f64; 4]]| { + let total: f64 = boxes.iter().map(area).sum(); + if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 } + }; + let inked = fraction(&line.ink) >= COVER_FRACTION; + let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION; + let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0); + inked || (boxed && (line.ink.is_empty() || word_intersects())) +} + +/// The area of a rectangle, 0 when it is empty or inverted. +fn area(r: &[f64; 4]) -> f64 { + (r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0) } /// WCAG 2.x relative luminance of an sRGB triple. diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ff20f5e849..d7d83a7db3 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -228,6 +228,93 @@ fn self_drawing_form_fails_the_audit() { assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } +/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the +/// text was, then the redaction box. At the end of a line, the box covers most of the line's box +/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged. +#[test] +fn applied_redaction_at_the_end_of_a_line_is_not_flagged() { + let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); + // The same gap between two words, wider than the words beside it. + let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +/// The control: the same box over text still drawn under it is a faux redaction, at the end of a +/// line as in the middle of a short one. +#[test] +fn faux_redaction_at_the_end_of_a_line_is_found() { + let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com"); + let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); +} + +/// A cover over a short value after a long label hides less than 40% of the line's words, but the +/// whole of the words it covers, and most of the line's box: still a faux redaction. The value +/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`. +#[test] +fn faux_redaction_over_a_short_value_after_a_long_label_is_found() { + let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789"); + let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("4111"), "{findings:?}"); +} + +/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a +/// cover over the address it draws is still found. +#[test] +fn faux_redaction_over_mirrored_text_is_found() { + let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com"); +} + +/// A cover over less than half a value must retain the historical line-box detection. +#[test] +fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() { + partial_value_is_found(false); +} + +#[test] +fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() { + partial_value_is_found(true); +} + +fn partial_value_is_found(rotated: bool) { + let bytes = one_page( + b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n", + if rotated { "/Rotate 90" } else { "" }, + ); + let findings = audit_redactions(&open(bytes)).expect("audit must run"); + assert_eq!(findings.len(), 1, "partial value, rotated={rotated}"); +} + +/// Keep the historic detections with several covers, lines and text-spacing states. +#[test] +fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() { + let cases: [&[u8]; 4] = [ + b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", + b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", + b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", + b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", + ]; + for content in cases { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content)); + } +} + #[test] fn rectangle_drawn_as_a_path_covers_text_like_re() { // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 43705c88fa..a10c5acdf8 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3137,7 +3137,11 @@ fn removing_hidden_information_through_tools() { /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. fn faux_redacted_fixture() -> Vec { - let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f"; + helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f") +} + +/// A one-page US Letter PDF drawing `body` with Helvetica as /F1. +fn helvetica_page(body: &str) -> Vec { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(), @@ -3179,6 +3183,25 @@ fn auditing_faux_redactions_through_tools() { assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0); } +/// An applied redaction at the end of a line leaves the words before it, then a gap the text +/// matrix jumps over and the redaction box: nothing is under the box any more, so the audit must +/// not report the words beside it as covered. In the middle of a line it never did. +#[test] +fn an_applied_redaction_at_the_end_of_a_line_audits_clean() { + let dir = workdir("audit-applied-end-of-line"); + let body = "BT /F1 11 Tf 72 692 Td (Mail the team: someone@example.com) Tj ET\nBT /F1 11 Tf 72 662 Td (Account 0042-7788 is in the middle of this line.) Tj ET"; + std::fs::write(dir.join("mail.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "mail.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "email" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "0042-7788" })); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string(); + assert!(text.contains("Mail the team:") && !text.contains("someone") && !text.contains("0042"), "{text}"); + let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc })); + assert_eq!(r["count"], 0, "{r}"); +} + #[test] fn printing_through_tools() { let dir = workdir("print"); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 839ba00eec..912cb3c904 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1346,6 +1346,28 @@ fn independent_text_objects_with_different_transforms_stay_separate() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2); } +/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's +/// `ink` box still spans all its glyphs, not only the first. +#[test] +fn word_boxes_span_whole_words_when_glyphs_run_backwards() { + // "someone@example.com" in Helvetica 11 is 121.814 pt wide. + for content in [ + "BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET", + "BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET", + ] { + let lines = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(lines.len(), 1, "{content}"); + let ink = &lines[0].ink; + assert_eq!(ink.len(), 4, "{content}: {ink:?}"); + let width = |r: &[f64; 4]| r[2] - r[0]; + assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}"); + let address = ink.iter().map(width).fold(0.0, f64::max); + assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}"); + let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2]))); + assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect); + } +} + #[test] fn text_lines_are_found_and_replaced_in_place() { let mut doc = diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index bf310156dd..1982463174 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -31,6 +31,10 @@ pub struct TextLine { pub text: String, /// Its box in user space. pub rect: [f64; 4], + /// The boxes of the words it draws, in user space: from each word's first glyph to the end of + /// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that + /// draw nothing, such as the adjustment an applied redaction leaves where text was removed. + pub ink: Vec<[f64; 4]>, /// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space. pub font: String, pub base_font: String, @@ -473,6 +477,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// The boxes of its words (see [`TextLine::ink`]). + ink: Vec<[f64; 4]>, baseline: f64, start_x: f64, end_x: f64, @@ -567,6 +573,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); // Only right-to-left ActualText is already in typing order and must stay @@ -667,16 +674,34 @@ fn interpret( let mut text = String::new(); let mut decodable = true; let mut x_text = 0.0; + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; let mut glyph_count = 0_usize; for p in &pieces { match p { Object::String(s) => { for (code, len) in m.codes(&s.bytes) { glyph_count = glyph_count.saturating_add(1); - match m.text_of(code) { + let shown = m.text_of(code); + let blank = + m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { Some(t) => text.push_str(&t), None => decodable = false, } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; x_text += w * ts.scale; } @@ -688,6 +713,11 @@ fn interpret( if dx > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); } + // Kerning keeps a word together; a jump either way of more + // than 0.2 em ends it (whatever the sign of the size or `Tz`). + if dx.abs() > (ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } x_text += dx; } } @@ -705,6 +735,15 @@ fn interpret( let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size); + [trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)] + .iter() + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; // A numeric-only TJ adjusts the current text matrix but paints no glyphs. // It must not create a phantom editable line or enlarge its neighbours' boxes. @@ -723,6 +762,7 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + ink, baseline: start.1, start_x: start.0, end_x: end.0, @@ -798,6 +838,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -808,6 +849,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), size: s.size, @@ -885,6 +927,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -894,6 +937,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) lines.push(TextLine { text: s.text.clone(), rect: s.rect, + ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), size: s.size, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bfc05d1075..2864e37e0a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5800,8 +5800,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." [[feature]] id = "protect.remove-hidden-on-close" From b9f0f84ff879157330394f81595bb4a8bd6de01f Mon Sep 17 00:00:00 2001 From: Alex <270190108+CouchRiv@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:28:01 -0400 Subject: [PATCH 058/207] fix(a11y): bookmarks from structure keep the words of glyph-by-glyph headings (#1899) * fix(a11y): bookmarks from structure keep the words of glyph-by-glyph headings Chromium (Skia) draws text one glyph at a time, each moved into place with its own Td. The marked text of a heading added a space at every Td, TD, T* and Tm, so New Bookmarks from Structure titled them "C h a p t e r T w o", and the real spaces between words were lost. On a 20-page tagged guide printed by Chromium, 25 of 61 titles came out spaced. mcid_texts now follows the text matrix (and the CTM, q/Q, Tc, Tw, Tz, TL) and decides word breaks from where the glyphs are: a glyph that starts on another line, more than 0.2 em after the previous glyph of the same marked content, or back over more than an em, starts a new word. Kerning and glyph-by-glyph placement no longer split words. Co-Authored-By: Claude Opus 5.5 * fix(a11y): headings set with a negative font size keep their words The em that decides word breaks was the font size times |Tz|, negative with a negative font size: every glyph then started a new word, and a heading in one Tj read "C h a p t e r O n e". The em is now |size x Tz|, and "ahead" follows the way glyphs advance in text space, backwards when the size or Tz is negative, so a TJ word space still splits words there as on main. Co-Authored-By: Claude Opus 5.5 * fix(a11y): keep wrapped headings separate under horizontal scaling Co-Authored-By: OpenAI Codex --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: OpenAI Codex --- crates/a11y/src/headings.rs | 153 +++++++++++++++++++++++++++++------ crates/a11y/src/tests.rs | 107 ++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 3 files changed, 238 insertions(+), 26 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 7a35deb469..96f99b9a9f 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; +use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_model::Page; @@ -171,13 +172,24 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict { } /// The text each marked-content id shows on a page (text drawn directly in the page content). +/// +/// Word breaks come from where the glyphs are, as on the page: a glyph that starts on another +/// line, more than 0.2 em past the end of the previous glyph of the same id, or back over more +/// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]). +/// Text-positioning operators alone don't break words: producers that place every glyph with its +/// own `Td` (Chromium) would otherwise read as "C h a p t e r". fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); - let mut font: Option> = None; let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); + // Where each id's last glyph ended, in user space. + let mut ends: HashMap = HashMap::new(); + let mut gs = TextState::default(); + let mut saved: Vec = Vec::new(); + let mut unsaved = 0usize; + let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { let id = stack.iter().rev().find_map(|m| *m); match op.op.as_slice() { @@ -192,8 +204,22 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { b"EMC" => { stack.pop(); } + b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" => unsaved += 1, + b"Q" if unsaved > 0 => unsaved -= 1, + b"Q" => { + if let Some(saved) = saved.pop() { + gs = saved; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + gs.ctm = Matrix(m).then(&gs.ctm); + } + } b"Tf" => { - font = op.name(0).map(<[u8]>::to_vec); + gs.size = op.num(1).unwrap_or(gs.size); + gs.font = op.name(0).map(<[u8]>::to_vec); if let Some(name) = op.name(0) && !metrics.contains_key(name) && let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) @@ -201,34 +227,113 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f)); } } - _ => {} - } - let (Some(id), Some(m)) = (id, font.as_ref().and_then(|f| metrics.get(f))) else { continue }; - let text = out.entry(id).or_default(); - if text.len() >= MAX_TITLE * 4 { - continue; - } - match op.op.as_slice() { - b"Tj" | b"'" | b"\"" => { - if op.op != b"Tj" { - text.push(' '); - } - if let Some(s) = op.operands.iter().rev().find_map(Object::as_string) { - text.push_str(&m.decode(&s.bytes)); + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"BT" => (tm, tlm) = (Matrix::IDENTITY, Matrix::IDENTITY), + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.op == b"TD" { + gs.leading = -y; + } + tlm = Matrix::translate(x, y).then(&tlm); + tm = tlm; } } - b"TJ" => { - for item in op.operands.first().and_then(Object::as_array).into_iter().flatten() { - match item { - Object::String(s) => text.push_str(&m.decode(&s.bytes)), - n if n.as_f64().is_some_and(|n| n < -200.0) => text.push(' '), - _ => {} + b"Tm" => { + if let Some(m) = op.nums::<6>() { + (tm, tlm) = (Matrix(m), Matrix(m)); + } + } + b"T*" => { + tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); + tm = tlm; + } + _ => {} + } + let shows = matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\""); + let Some(m) = gs.font.as_ref().and_then(|f| metrics.get(f)).filter(|_| shows) else { continue }; + if op.op != b"Tj" && op.op != b"TJ" { + if op.op == b"\"" { + gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); + gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); + } + tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); + tm = tlm; + } + let pieces: Vec<&Object> = match op.op.as_slice() { + b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(), + _ => op.operands.last().into_iter().collect(), + }; + for piece in pieces { + let s = match piece { + Object::String(s) => s, + other => { + // A TJ adjustment moves the next glyph; whether it breaks a word is decided there. + let dx = -other.as_f64().unwrap_or(0.0) / 1000.0 * gs.size * gs.scale; + tm = Matrix::translate(dx, 0.0).then(&tm); + continue; + } + }; + for (code, len) in m.codes(&s.bytes) { + let trm = tm.then(&gs.ctm); + if let Some(id) = id + && let Some(glyph) = m.text_of(code) + { + let text = out.entry(id).or_default(); + if text.len() < MAX_TITLE * 4 { + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); + } + text.push_str(&glyph); } } + let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; + tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); + if let Some(id) = id { + ends.insert(id, tm.then(&gs.ctm).apply(0.0, 0.0)); + } } - b"Td" | b"TD" | b"T*" | b"Tm" => text.push(' '), - _ => {} } } out } + +/// How deep `q` nests before the text state stops being saved (a hostile stream can nest millions). +const MAX_SAVED: usize = 256; + +/// The graphics state that places glyphs: the CTM, the font and the text state parameters. +#[derive(Clone)] +struct TextState { + ctm: Matrix, + font: Option>, + size: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + leading: f64, +} + +impl Default for TextState { + fn default() -> Self { + TextState { ctm: Matrix::IDENTITY, font: None, size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0 } + } +} + +/// Does a glyph drawn with text rendering matrix `trm` (text space → user space) start a new word +/// after a glyph that ended at `end` (user space)? Yes on another line (more than half the absolute +/// font size up or down), more than 0.2 horizontal em ahead, or back over more than a horizontal em. +/// The horizontal em is the absolute value of font size times horizontal scale (`Tz`); "ahead" follows +/// the glyph advance, backwards when the size or `Tz` is negative. `Tz` never scales the vertical threshold. +fn new_word(trm: &Matrix, end: (f64, f64), font_size: f64, horizontal_scale: f64) -> bool { + let advance = font_size * horizontal_scale; + let em = advance.abs(); + let start = trm.apply(0.0, 0.0); + let [a, b, c, d, _, _] = trm.0; + let Some(back) = Matrix([a, b, c, d, 0.0, 0.0]).invert() else { return true }; + let (dx, dy) = back.apply(start.0 - end.0, start.1 - end.1); + let ahead = if advance < 0.0 { -dx } else { dx }; + !(ahead.is_finite() && dy.is_finite()) || dy.abs() > font_size.abs() * 0.5 || ahead > em * 0.2 || ahead < -em +} diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 6e23c5c6a0..b0ace2c034 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -673,6 +673,105 @@ fn headings_are_found_with_their_levels_titles_and_pages() { ); } +/// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw. +fn helvetica_width(c: char) -> f64 { + match c { + 'C' | 'w' => 722.0, + 'P' => 667.0, + 'T' | 'F' => 611.0, + 'v' => 500.0, + 'r' => 333.0, + 't' | ' ' => 278.0, + 'i' => 222.0, + _ => 556.0, // a e h o p + } +} + +/// `text` drawn one glyph at a time from (72, y), each glyph moved into place with its own `Td`, +/// as Chromium writes text. With `spaces`, the space is drawn too; without, the next glyph is +/// just moved past it. +fn glyph_by_glyph(text: &str, size: f64, y: f64, spaces: bool) -> String { + let mut ops = format!("BT /F1 {size} Tf 72 {y} Td"); + let mut step = 0.0; + for c in text.chars() { + if c == ' ' && !spaces { + step += helvetica_width(c) / 1000.0 * size; + continue; + } + ops.push_str(&format!(" {step:.3} 0 Td ({c}) Tj")); + step = helvetica_width(c) / 1000.0 * size; + } + ops + " ET" +} + +/// `text` drawn one glyph at a time with `/F1 1 Tf` and a `Tm` per glyph scaled to `size`; the +/// space is skipped over, not drawn. +fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { + let mut ops = "BT /F1 1 Tf".to_string(); + let mut x = 72.0; + for c in text.chars() { + if c != ' ' { + ops.push_str(&format!(" {size} 0 0 {size} {x:.3} {y} Tm ({c}) Tj")); + } + x += helvetica_width(c) / 1000.0 * size; + } + ops + " ET" +} + +/// One tagged page whose H1 headings show `contents` (one marked-content sequence each). +fn heading_page(contents: &[String]) -> Document { + let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); + let kids: Vec = (0..contents.len()).map(|i| format!("{} 0 R", 7 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> >> >>".into(), + stream(&marked.join("\n")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + FONT.into(), + ]; + objs.extend((0..contents.len()).map(|i| format!("<< /S /H1 /Pg 3 0 R /K {i} >>"))); + pdf(&objs, "") +} + +/// Chromium (Skia) places each glyph with its own `Td`: a heading must read as its words, not as +/// "C h a p t e r T w o", whether the space is drawn or only skipped over. A heading set with a +/// negative font size or `Tz` keeps its words too. +#[test] +fn headings_drawn_glyph_by_glyph_keep_their_words() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chapter One) Tj ET".into(), + glyph_by_glyph("Chapter Two", 20.0, 650.0, true), + glyph_by_glyph("Chapter Three", 20.0, 600.0, false), + // Each glyph placed with its own Tm, at font size 1 scaled by the matrix. + glyph_by_tm("Part Five", 18.0, 550.0), + // Kerning inside a word, then a word space as a TJ adjustment, then a wrapped line. + "BT /F1 16 Tf 72 450 Td [(Ch) 40 (apter) -350 (Four)] TJ 0 -20 Td (continued) Tj ET".into(), + // A negative font size runs the glyphs backwards in text space: alone, in a mirrored + // text matrix (upright on the page), with a word space as a TJ adjustment, and the same + // with a negative horizontal scale instead (reset after, as Tz outlives the text object). + "BT /F1 -20 Tf 72 380 Td (Chapter Six) Tj ET".into(), + "BT /F1 -20 Tf -1 0 0 -1 300 330 Tm (Chapter Seven) Tj ET".into(), + "BT /F1 -20 Tf 72 280 Td [(Chapter) -300 (Eight)] TJ ET".into(), + "BT /F1 20 Tf -100 Tz 300 230 Td [(Chapter) -300 (Nine)] TJ ET 100 Tz".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!( + titles, + [ + "Chapter One", + "Chapter Two", + "Chapter Three", + "Part Five", + "Chapter Four continued", + "Chapter Six", + "Chapter Seven", + "Chapter Eight", + "Chapter Nine" + ] + ); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); @@ -769,3 +868,11 @@ fn a_nested_finding_says_itself() { // Both wordings are translated, each on its own, and the document's words are left alone. assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»"); } + +/// Horizontal stretching must not raise the vertical line-break threshold. +#[test] +fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { + let doc = heading_page(&["BT /F1 20 Tf 400 Tz 72 700 Td (I) Tj 0 -30 Td (Overview) Tj ET".into()]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["I Overview"]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2864e37e0a..4900ffc77b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3328,8 +3328,8 @@ milestone = "M4" status = "shipped" commands = ["bookmark.from_structure"] tools = ["bookmark_from_structure"] -evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] -notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" +evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] +notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" [[feature]] id = "organize.attachments-add" From b5dc84df2a0d0b07b63ca829a38433c0223b85d3 Mon Sep 17 00:00:00 2001 From: Alex <270190108+CouchRiv@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:28:25 -0400 Subject: [PATCH 059/207] fix(audit): report faux-redaction rectangles in top-left page points (#1900) doc_audit_redactions and `pdfcraft-cli audit` returned rectangles in PDF user space (origin at the bottom-left of the media box), while comment_list, doc_info, text_lines and the redaction tools give points from the top-left of the page as displayed. The same mark read [144.14, 91.2, 265.95, 102.2] in comment_list and [144.138, 739.8, 265.952, 750.8] in the audit. Both surfaces now convert with the helper doc_info uses (view_rect, made public for the CLI), so they report the same rectangles, after /Rotate. The CLI reads the page geometry only when there is a finding to place. AuditFinding::rect stays in user space; the audit README says which space each surface uses. Co-authored-by: Claude Opus 5.5 Co-authored-by: OpenAI Codex Co-authored-by: Brandon Thomas --- apps/pdfcraft-cli/src/main.rs | 9 +++- apps/pdfcraft-cli/tests/audit.rs | 66 +++++++++++++++++++++++++++ crates/audit/README.md | 4 +- crates/automation/src/lib.rs | 8 ++-- crates/automation/src/redact.rs | 6 ++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 3 ++ parity/acrobat-features.toml | 2 + 8 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/audit.rs diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 190cb3da9f..7f053e055f 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -161,15 +161,20 @@ fn audit(args: &[String]) -> Result<(), CliError> { validate_options("audit", args, AUDIT_OPTIONS)?; let path = *positional(args).first().ok_or("audit: missing file")?; let bytes = read(path)?; - let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?; + let password = flag(args, "--password"); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; + // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` + // reports them; the page geometry is read only when there is something to place. + let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) }; let items: Vec = findings .into_iter() .map(|f| { + let rect = f.rect.map(|v| v as f32); serde_json::json!({ "page": f.page + 1, "kind": f.kind.id(), - "rect": f.rect, + "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), "covered_text": f.covered_text, }) }) diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/audit.rs new file mode 100644 index 0000000000..eb99fa11d5 --- /dev/null +++ b/apps/pdfcraft-cli/tests/audit.rs @@ -0,0 +1,66 @@ +//! `audit` reports the rectangles of faux redactions where `doc_audit_redactions` does: in +//! points from the top-left of the page as displayed, after `/Rotate`. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// Two US Letter pages with "TOP SECRET" under an opaque `90 688 220 32 re`; the second page is +/// turned a quarter (`/Rotate 90`). +fn fixture() -> Vec { + let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// The `rect` of each finding, in page order. +fn rects(report: &serde_json::Value) -> Vec { + report["findings"].as_array().expect("findings").iter().map(|f| f["rect"].clone()).collect() +} + +#[test] +fn audit_rectangles_match_the_tool_in_top_left_points() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-audit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("faux.pdf"), fixture()).unwrap(); + + let out = Command::new(BIN).arg("audit").arg(dir.join("faux.pdf")).output().unwrap(); + assert!(!out.status.success(), "findings must fail the audit"); + let cli: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(cli["count"], 2, "{cli}"); + // Page 1 upright: y measured down from the top of the 792 pt page. Page 2 turned a quarter + // clockwise: 792 pt wide, and the cover's user-space y becomes its x. + let expected = [serde_json::json!([90.0, 72.0, 310.0, 104.0]), serde_json::json!([688.0, 90.0, 720.0, 310.0])]; + assert_eq!(rects(&cli), expected, "{cli}"); + + // The tool reports the same rectangles. + let steps = serde_json::json!([ + { "tool": "doc_open", "args": { "path": "faux.pdf" } }, + { "tool": "doc_audit_redactions", "args": { "doc": 1 } }, + ]); + std::fs::write(dir.join("steps.json"), steps.to_string()).unwrap(); + let out = Command::new(BIN).arg("run").arg("--script").arg(dir.join("steps.json")).arg("--root").arg(&dir).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + let tool = serde_json::Deserializer::from_str(&stdout).into_iter::().last().unwrap().unwrap(); + assert_eq!(rects(&tool), rects(&cli), "{tool}"); +} diff --git a/crates/audit/README.md b/crates/audit/README.md index f904680153..5dd15de3af 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -35,4 +35,6 @@ Limits, by design and documented rather than hidden: It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". -Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`. +Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each +rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like +the other tools; `AuditFinding::rect` itself stays in PDF user space. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 397edbd131..d313ee8351 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2253,9 +2253,11 @@ fn info(d: &Document) -> Value { }) } -/// A user-space rectangle on 0-based `page` in displayed-page coordinates; unchanged when the -/// page is unknown (a malformed annotation that points at no page). -fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 60d8c8b77f..1b6283c0ad 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -173,14 +173,16 @@ impl Automation { } pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result { - let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?; + let d = self.doc(a)?; + let findings = d.audit_redactions().map_err(ToolError::Failed)?; let items: Vec = findings .into_iter() .map(|f| { json!({ "page": f.page + 1, "kind": f.kind.id(), - "rect": f.rect, + // Points from the top-left of the displayed page, like `comment_list` and `doc_info`. + "rect": crate::view_rect(&d.info, f.page, f.rect.map(|v| v as f32)), "covered_text": f.covered_text, }) }) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e4b1445e2d..dcc06f5f03 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -474,7 +474,7 @@ pub fn tools() -> Vec { t( "doc_audit_redactions", "Audit faux redactions", - "Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.", + "Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle (points from the top-left of the displayed page, like comment_list), and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.", ) .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a10c5acdf8..cdb11b685a 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3178,6 +3178,9 @@ fn auditing_faux_redactions_through_tools() { assert_eq!(f["page"], 1); assert_eq!(f["kind"], "covered-text"); assert!(f["covered_text"].as_str().unwrap().contains("TOP SECRET"), "{f}"); + // Points from the top-left of the page, like every other tool (the cover is `90 688 220 32 re` + // on a 792 pt high page). + assert_eq!(f["rect"], json!([90.0, 72.0, 310.0, 104.0]), "{f}"); // A clean document audits clean. let clean = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4900ffc77b..ad337a87e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5802,6 +5802,8 @@ commands = [] tools = ["doc_audit_redactions"] evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"] notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" From 16d919807752779e0a5ce21dc0b36d4018101a1c Mon Sep 17 00:00:00 2001 From: Eugene Hinderer Date: Thu, 15 Oct 2026 02:28:36 -0400 Subject: [PATCH 060/207] fix: render CCITT Group 4 images that declare /Rows 0 (#1903) ISO 32000-2 Table 11 defines /Rows 0 (the default) as "height not predetermined", but the vendored hayro-syntax decoder took it as zero rows, so scanned pages written that way rendered blank. Treat 0 like an absent /Rows and fall back to the image height. Adds a synthetic regression test: an 8x4 all-black Group 4 stream, hand-encoded from ITU-T T.6, rendered with /Rows absent, 4 and 0. It fails without the patch. Co-authored-by: Claude Opus 5.5 --- crates/render/src/raster.rs | 32 +++++++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-syntax/src/filter/ccitt.rs | 7 +++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 60a0ecc66a..a52a4b82f5 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3480,6 +3480,38 @@ trailer << /Root 1 0 R >> page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax); } + /// Scanners and fax services write Group 4 pages with `/Rows 0`, which ISO 32000-2 Table 11 + /// defines as "height not predetermined" (the default), not as zero rows. Such a page used to + /// render blank. The image is an 8 × 4 all-black Group 4 stream, hand-encoded from ITU-T T.6: + /// one horizontal-mode row (white 0, black 8), three V0 rows, then EOFB. + #[test] + fn group4_fax_with_rows_zero_decodes_to_the_image_height() { + const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + for rows in ["", "/Rows 4", "/Rows 0"] { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} >> /Length {} >> stream\n", + content.len(), + ALL_BLACK_8X4.len() + ) + .into_bytes(); + pdf.extend_from_slice(&ALL_BLACK_8X4); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{rows:?}: {:?}", page.error); + assert_eq!(&page.rgba[((24 * 40 + 15) * 4)..][..4], &[0, 0, 0, 255], "{rows:?}: the fax image paints black"); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 255, 255, 255], "{rows:?}: outside the image stays white"); + } + } + #[test] fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() { use hayro_syntax::object::{FromBytes, Object}; diff --git a/vendor/README.md b/vendor/README.md index 33248d09d4..564e505797 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -15,7 +15,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 98924f2dbd..687d087149 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -15,7 +15,12 @@ pub(crate) fn decode( ) -> Option> { let k = params.get::(K).unwrap_or(0); - let rows = params.get::(ROWS).unwrap_or(image_params.height); + // PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and + // the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank. + let rows = params + .get::(ROWS) + .filter(|&rows| rows != 0) + .unwrap_or(image_params.height); let end_of_block = params.get::(END_OF_BLOCK).unwrap_or(true); let settings = DecodeSettings { From aed3afdc2094a06fa715a95b8d22579b8965280e Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:28:46 +0800 Subject: [PATCH 061/207] fix(automation): a missing path under --root says where it was looked for (#1890) (#1907) A relative path that doesn't exist below the root was refused with the path as typed and the system's localised message, so a script written for another working directory ("sub/a.pdf" with --root sub) gave nothing to correct from. The refusal now names the resolved path and the root; paths outside the root keep their uniform refusal. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/automation/src/lib.rs | 13 +++++++++++-- crates/automation/tests/automation.rs | 25 ++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d313ee8351..7b8e166d81 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1938,8 +1938,17 @@ impl Automation { return Err(outside()); } if !rest.is_empty() && !for_write { - // Missing, below a folder inside the root: say why, as the system reports it. - real = joined.canonicalize().map_err(|e| failed(format!("{path}: {e}")))?; + // Missing, below a folder inside the root: say where it was looked for, so a path + // written for another working directory can be corrected (#1890), and otherwise + // why, as the system reports it. `joined` is under the root, so naming it reveals + // nothing outside. + real = joined.canonicalize().map_err(|e| { + let (looked, root) = (joined.display(), root.display()); + match e.kind() { + std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), + _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), + } + })?; if !real.starts_with(root) { return Err(outside()); // it appeared, as a link out, since the check above } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cdb11b685a..e3eaf4ce52 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -857,7 +857,10 @@ fn root_refusals_do_not_reveal_what_exists_outside() { // Inside the root nothing changes: missing files say so, and existing ones open and save, // however the path is spelled. let missing = a.call("doc_open", &json!({ "path": "missing-inside.pdf" })).unwrap_err(); - assert!(matches!(&missing, ToolError::Failed(m) if m.starts_with("missing-inside.pdf: ") && !m.contains("outside")), "{missing:?}"); + assert!( + matches!(&missing, ToolError::Failed(m) if m.starts_with("missing-inside.pdf not found (looked for ") && !m.contains("outside")), + "{missing:?}" + ); let not_dir = a.call("doc_open", &json!({ "path": "inside.pdf/x" })).unwrap_err(); assert!(matches!(¬_dir, ToolError::Failed(m) if !m.contains("outside")), "{not_dir:?}"); #[cfg_attr(not(windows), allow(unused_mut))] @@ -5939,3 +5942,23 @@ fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() { assert_eq!(opened["document"]["encrypted"], true); assert_eq!(opened["destination_applied"], true); } + +#[test] +fn a_missing_path_under_the_root_names_where_it_was_looked_for() { + // #1890: a script written from another working directory repeats the root's folder in its + // paths. The refusal names the resolved path and the root, so there is something to correct. + let (base, root) = sandbox("resolved-path"); + let mut a = auto(&root); + let looked = |rel: &str| root.join(rel).display().to_string(); + for (path, rel) in + [("root/inside.pdf", "root/inside.pdf"), ("missing/deeper/x.pdf", "missing/deeper/x.pdf"), ("./sub/../root/inside.pdf", "root/inside.pdf")] + { + let ToolError::Failed(m) = err(&mut a, "doc_open", json!({ "path": path })) else { panic!("{path}: not a failure") }; + assert_eq!(m, format!("{path} not found (looked for {}, --root {})", looked(rel), root.display())); + } + // The same mistake with a file that does exist outside still gets the plain refusal (#136). + let ToolError::Failed(m) = err(&mut a, "doc_open", json!({ "path": "../outside/secret.pdf" })) else { panic!("not a failure") }; + assert!(!m.contains("looked for"), "{m}"); + ok(&mut a, "doc_open", json!({ "path": "inside.pdf" })); + let _ = std::fs::remove_dir_all(base); +} From 6882275ee5d154528040983ec5d8e7642d0961c9 Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Thu, 15 Oct 2026 09:28:56 +0300 Subject: [PATCH 062/207] M3: touchpad swipes glide on after the fingers lift on Wayland (#531) (#1909) Wayland compositors leave momentum to the client: libinput reports the swipe as pixel deltas and the lift as an axis stop (TouchPhase::End), and the page view stopped dead there (#1708). It now measures the gesture's speed over its last 0.1 s and keeps scrolling through the #759 1:1 path, slowing with a 0.35 s time constant. - No glide for a slow drag, a pause before lifting, zoom or Shift swipes, single-page paging, or with Animation effects off. - Any wheel input, click, key, zoom, dialog or the document's edge ends it. - Off on macOS (it sends its own momentum) and the web; Windows and X11 report no touch phases through winit, so nothing starts a glide there. Not yet: fingers resting on the touchpad don't catch the glide; that needs Wayland hold gestures, which the vendored winit doesn't bind. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 29 +++- crates/ui-egui/src/fling.rs | 276 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/view.rs | 80 ++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/fling.rs diff --git a/ROADMAP.md b/ROADMAP.md index ed84b6ab4e..e34a00af69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. - **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. +- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f2f60a742e..fc4366ff06 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -396,6 +396,8 @@ pub struct DocView { /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as /// it decays, instead of adding on top of the 1:1 motion. precise_scroll_until: f64, + /// Touchpad momentum after the fingers lift (#531). + fling: crate::fling::Fling, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page". pub selected: BTreeSet, @@ -650,6 +652,7 @@ impl DocView { shown: false, wheel: Default::default(), precise_scroll_until: 0.0, + fling: Default::default(), auto_scroll: Default::default(), selected: BTreeSet::new(), select_anchor: None, @@ -2278,6 +2281,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { precise_scroll = Vec2::ZERO; } + // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a + // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or + // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. + // Asked of the preference, not of egui's animation time, which test harnesses zero. + let animates = app.animation_effects.animates(app.motion.reduced); + let may_glide = !single_paging && !middle_gesture && navigation_enabled && unobstructed && animates; + let over_page = ui.rect_contains_pointer(avail); + ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + view.fling.stop(); + } + let glide = view.fling.step(now); + if view.fling.gliding() { + ui.ctx().request_repaint(); + } let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; let (y_shift, content_h) = if view.continuous { (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) @@ -2434,9 +2453,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Only interactions pause; the document must keep its original colours. ui.set_opacity(opacity); } - // Middle-button auto-scroll, the keyboard and precise touchpad input (#759); positive y - // moves the content down. All three scroll without easing, unlike egui's wheel default. - let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll; + // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide + // (#531); positive y moves the content down. All scroll without easing, unlike egui's + // wheel default. + let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll + glide; if delta != Vec2::ZERO { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } @@ -3227,6 +3247,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.pan_hold_until = now_settle + 0.25; ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); } + if glide != Vec2::ZERO { + view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); + } view.last_scroll = scroll_offset; let pan_held = now_settle < view.pan_hold_until; let (mut wanted, visible_now) = out.inner; diff --git a/crates/ui-egui/src/fling.rs b/crates/ui-egui/src/fling.rs new file mode 100644 index 0000000000..cce004ce53 --- /dev/null +++ b/crates/ui-egui/src/fling.rs @@ -0,0 +1,276 @@ +//! Touchpad momentum (#531): a two-finger swipe keeps gliding after the fingers lift, and slows +//! to a stop. +//! +//! Wayland compositors leave momentum to the client. libinput reports the swipe as pixel deltas +//! (`MouseWheelUnit::Point`) and the lift as an axis stop, which winit passes on as +//! `TouchPhase::End`; GTK and Firefox glide on from there, and so does the page view. +//! +//! macOS sends its own momentum as a second `Start`…`End`, so gliding there would glide twice, +//! and the web has no touch phases: [`Fling::enabled`] is off on both. Windows and X11 report no +//! touch phases through winit either, so no `End` ever starts a glide there. + +use egui::{Event, MouseWheelUnit, TouchPhase, Vec2}; + +/// Seconds of the gesture the lift speed is measured over. +const WINDOW: f64 = 0.1; +/// A lift this long after the last motion is a finger that stopped first: no glide. +const STILL: f64 = 0.05; +/// The glide slows by `e` every this many seconds; it travels the lift speed times this. +const TAU: f32 = 0.35; +/// Below this speed (points per second) a lift doesn't glide, and a glide stops. +const START_SPEED: f32 = 150.0; +const STOP_SPEED: f32 = 10.0; +/// Fastest glide, in points per second: a flick the system measured over a hiccup can't throw +/// the document to its end. +const MAX_SPEED: f32 = 8000.0; +/// A pass this long after the last one (the view was hidden, the app stalled) ends a glide. +const STALE: f64 = 0.1; + +pub(crate) struct Fling { + /// Whether a lift glides on this platform (see the module docs). + pub(crate) enabled: bool, + /// The gesture in progress: when each pass's motion arrived, and how far it went. + samples: Vec<(f64, Vec2)>, + /// The glide's speed in points per second (positive moves the content down), or zero. + velocity: Vec2, + /// When the glide last moved. + at: f64, + /// Passes in a row the glide moved the view not at all: it is against an edge. + stalled: u8, +} + +impl Default for Fling { + fn default() -> Self { + Self::new(!cfg!(any(target_os = "macos", target_arch = "wasm32"))) + } +} + +impl Fling { + pub(crate) fn new(enabled: bool) -> Self { + Self { enabled, samples: Vec::new(), velocity: Vec2::ZERO, at: 0.0, stalled: 0 } + } + + pub(crate) fn gliding(&self) -> bool { + self.velocity != Vec2::ZERO + } + + pub(crate) fn stop(&mut self) { + self.velocity = Vec2::ZERO; + self.stalled = 0; + } + + /// Reads one pass's wheel events, at time `now`. Any wheel input stops a glide; a touchpad + /// gesture's `End` may start one. `glide` is false where the view must not glide this pass + /// (animations off, single-page paging, a zoom, a dialog). + pub(crate) fn read(&mut self, events: &[Event], now: f64, glide: bool) { + for event in events { + let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; + self.stop(); + // Zooming, a mouse wheel and sideways Shift scrolling never glide. + if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift { + self.samples.clear(); + continue; + } + match phase { + TouchPhase::Start => { + self.samples.clear(); + self.push(now, *delta); + } + TouchPhase::Move => self.push(now, *delta), + TouchPhase::End => { + if glide && self.enabled { + self.velocity = self.lift_velocity(now); + self.at = now; + } + self.samples.clear(); + } + TouchPhase::Cancel => self.samples.clear(), + } + } + } + + fn push(&mut self, now: f64, delta: Vec2) { + if !(delta.x.is_finite() && delta.y.is_finite()) { + return; + } + match self.samples.last_mut() { + // Several events in one pass are one sample: they share its time. + Some((at, d)) if *at == now => *d += delta, + _ => self.samples.push((now, delta)), + } + self.samples.retain(|(at, _)| now - at <= WINDOW); + } + + /// The finger's speed as it lifted at `now`, or zero when it should not glide. + fn lift_velocity(&self, now: f64) -> Vec2 { + let recent: Vec<&(f64, Vec2)> = self.samples.iter().filter(|(at, _)| now - at <= WINDOW).collect(); + let (Some(first), Some(last)) = (recent.first(), recent.last()) else { return Vec2::ZERO }; + let span = (last.0 - first.0) as f32; + if recent.len() < 2 || span <= 0.0 || now - last.0 > STILL { + return Vec2::ZERO; + } + // Each sample is the motion since the pass before it, so the first one's motion + // happened before the span starts. + let moved = recent.iter().skip(1).fold(Vec2::ZERO, |sum, (_, d)| sum + *d); + let v = moved / span; + if !(v.x.is_finite() && v.y.is_finite()) || v.length() < START_SPEED { + return Vec2::ZERO; + } + if v.length() > MAX_SPEED { v * (MAX_SPEED / v.length()) } else { v } + } + + /// How far the glide moves the view in the pass at `now` (zero when not gliding), as the + /// exact distance a speed decaying with [`TAU`] covers since the last pass. + pub(crate) fn step(&mut self, now: f64) -> Vec2 { + if !self.gliding() { + return Vec2::ZERO; + } + let dt = now - self.at; + if !(0.0..=STALE).contains(&dt) { + self.stop(); + return Vec2::ZERO; + } + self.at = now; + let decay = (-(dt as f32) / TAU).exp(); + let moved = self.velocity * TAU * (1.0 - decay); + self.velocity *= decay; + if self.velocity.length() < STOP_SPEED { + self.stop(); + } + moved + } + + /// After a pass the glide moved the view in: whether the offset changed. Two passes that + /// didn't move it mean an edge, and the glide ends there. + pub(crate) fn moved(&mut self, moved: bool) { + if moved { + self.stalled = 0; + } else { + self.stalled = self.stalled.saturating_add(1); + if self.stalled >= 2 { + self.stop(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Modifiers, vec2}; + + fn wheel(dy: f32, phase: TouchPhase) -> Event { + Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + } + + /// A swipe of `moves` passes of `dy` each, 60 per second, lifted the pass after the last. + fn swipe(fling: &mut Fling, dy: f32, moves: usize) -> f64 { + let dt = 1.0 / 60.0; + let mut now = 0.0; + fling.read(&[wheel(dy, TouchPhase::Start)], now, true); + for _ in 0..moves { + now += dt; + fling.read(&[wheel(dy, TouchPhase::Move)], now, true); + } + now += dt; + fling.read(&[wheel(0.0, TouchPhase::End)], now, true); + now + } + + #[test] + fn a_flick_glides_on_at_its_lift_speed_and_stops() { + let mut fling = Fling::new(true); + let mut now = swipe(&mut fling, -25.0, 6); + assert!(fling.gliding()); + // 25 points a pass at 60 passes a second. + assert!((fling.velocity.y + 1500.0).abs() < 1.0, "{:?}", fling.velocity); + let mut travelled = 0.0; + let mut passes = 0; + while fling.gliding() { + now += 1.0 / 60.0; + let d = fling.step(now); + assert!(d.y <= 0.0 && d.x == 0.0, "keeps the swipe's direction: {d:?}"); + travelled += d.y; + passes += 1; + assert!(passes < 600, "the glide ends"); + } + // Speed × TAU, less the tail under STOP_SPEED. + assert!((-travelled - 1500.0 * TAU).abs() < 10.0, "travelled {travelled}"); + assert_eq!(fling.step(now + 1.0 / 60.0), Vec2::ZERO); + } + + #[test] + fn a_slow_drag_or_a_pause_before_lifting_does_not_glide() { + let mut fling = Fling::new(true); + swipe(&mut fling, -1.0, 6); + assert!(!fling.gliding(), "60 points a second is below the start speed"); + + let mut fling = Fling::new(true); + let dt = 1.0 / 60.0; + fling.read(&[wheel(-25.0, TouchPhase::Start)], 0.0, true); + fling.read(&[wheel(-25.0, TouchPhase::Move)], dt, true); + fling.read(&[wheel(0.0, TouchPhase::End)], dt + 0.2, true); + assert!(!fling.gliding(), "the finger rested before it lifted"); + } + + #[test] + fn new_input_stops_a_glide() { + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + fling.read(&[wheel(-2.0, TouchPhase::Start)], now + 0.1, true); + assert!(!fling.gliding(), "fingers back on the touchpad"); + + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + let notch = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; + fling.read(&[notch], now + 0.1, true); + assert!(!fling.gliding(), "a wheel notch"); + } + + #[test] + fn no_glide_where_it_is_off_or_not_wanted() { + let mut off = Fling::new(false); + swipe(&mut off, -25.0, 6); + assert!(!off.gliding(), "macOS and the web glide by themselves or not at all"); + + let mut fling = Fling::new(true); + let dt = 1.0 / 60.0; + fling.read(&[wheel(-25.0, TouchPhase::Start)], 0.0, true); + fling.read(&[wheel(-25.0, TouchPhase::Move)], dt, true); + fling.read(&[wheel(-25.0, TouchPhase::Move)], 2.0 * dt, true); + fling.read(&[wheel(0.0, TouchPhase::End)], 3.0 * dt, false); + assert!(!fling.gliding(), "animations off, paging or a dialog"); + + let mut zoom = Fling::new(true); + let ctrl = |phase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -25.0), phase, modifiers: Modifiers::COMMAND }; + zoom.read(&[ctrl(TouchPhase::Start)], 0.0, true); + zoom.read(&[ctrl(TouchPhase::Move)], dt, true); + zoom.read(&[ctrl(TouchPhase::Move)], 2.0 * dt, true); + zoom.read(&[ctrl(TouchPhase::End)], 3.0 * dt, true); + assert!(!zoom.gliding(), "a pinch or ⌘/Ctrl swipe zooms"); + } + + #[test] + fn an_edge_a_stall_or_a_wild_speed_ends_or_caps_the_glide() { + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + fling.step(now + 1.0 / 60.0); + fling.moved(false); + assert!(fling.gliding(), "one pass may not show the move yet"); + fling.moved(false); + assert!(!fling.gliding(), "against the edge"); + + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + assert_eq!(fling.step(now + 1.0), Vec2::ZERO, "a second-long gap (the tab was hidden)"); + assert!(!fling.gliding()); + + let mut fling = Fling::new(true); + swipe(&mut fling, -5000.0, 6); + assert!((fling.velocity.length() - MAX_SPEED).abs() < 1.0, "{:?}", fling.velocity); + + let mut fling = Fling::new(true); + swipe(&mut fling, f32::NAN, 6); + assert!(!fling.gliding(), "a speed that isn't a number"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aef83403df..8aacc7eea0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,7 @@ mod editing; pub mod file_watch; mod files; pub mod fill_sign; +mod fling; pub mod folders_ui; pub mod forms_ui; mod home; diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index a48a86c07a..1f27a7314c 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -256,6 +256,86 @@ fn precise_touchpad_wheel_scrolls_one_to_one() { assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); } +/// Steps `frames` frames and returns how far the document scrolled down over them, from the +/// pages on screen in each pair of consecutive frames (a page can leave the screen on the way). +fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { + let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::>(); + let mut moved = 0.0; + for _ in 0..frames { + let before = tops(h); + h.step(); + let after = tops(h); + let step = before.iter().zip(&after).find_map(|(b, a)| Some(b.as_ref()? - a.as_ref()?)); + moved += step.expect("a page stays on screen from one frame to the next"); + } + moved +} + +/// A two-finger swipe down the page, 25 points a frame for six frames, lifted the frame after +/// (the axis stop Wayland sends), at 300 % so the document is long enough to glide in: how far +/// it scrolled by the frame after the lift (the page is drawn a frame behind its scroll). +fn swipe_and_lift(h: &mut Harness<'static, PdfCraftApp>) -> f32 { + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + h.event(wheel(MouseWheelUnit::Point, -25.0, TouchPhase::Start)); + let mut moved = scroll_over(h, 1); + for _ in 0..6 { + h.event(wheel(MouseWheelUnit::Point, -25.0, TouchPhase::Move)); + moved += scroll_over(h, 1); + } + h.event(wheel(MouseWheelUnit::Point, 0.0, TouchPhase::End)); + moved + scroll_over(h, 2) +} + +/// The touchpad glide is Wayland's: macOS sends its own momentum and the web none (#531). +#[cfg(not(target_os = "macos"))] +#[test] +fn touchpad_swipe_glides_on_after_the_fingers_lift_and_stops() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + let swiped = swipe_and_lift(&mut h); + // The seven 25-point motions, 1:1 (#759). + assert!(swiped >= 174.0, "the swipe scrolls 1:1: moved {swiped}"); + // 1500 points a second at the lift: about 190 points over the next 10 frames. + let glided = scroll_over(&mut h, 10); + assert!(glided > 100.0, "the page glides on after the lift: moved {glided} more"); + let rest = scroll_over(&mut h, 180); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "the glide slows to a stop: still drifting {drift}"); + let total = swiped - 175.0 + glided + rest; + assert!(total > 400.0 && total < 600.0, "about the lift speed × 0.35 s: glided {total}"); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn touching_the_touchpad_again_or_turning_animations_off_stops_the_glide() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + swipe_and_lift(&mut h); + assert!(scroll_over(&mut h, 3) > 30.0, "gliding"); + h.event(wheel(MouseWheelUnit::Point, 0.0, TouchPhase::Start)); + scroll_over(&mut h, 2); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "fingers back down catch the page: drifted {drift}"); + + // A click (a pressed touchpad, or a tap with tap-to-click) catches it too. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + swipe_and_lift(&mut h); + assert!(scroll_over(&mut h, 3) > 30.0, "gliding"); + let at = h.state().views[0].viewport_rect().center(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + scroll_over(&mut h, 2); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "a click catches the page: drifted {drift}"); + + // Animation effects off: the page stops with the fingers, as before #531. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", "off").unwrap(); + let swiped = swipe_and_lift(&mut h); + assert!((swiped - 175.0).abs() < 1.0, "the swipe still scrolls 1:1: moved {swiped}"); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "no glide with animations off: drifted {drift}"); +} + /// The 40 frames from one wheel notch on, with the system asking for no animations (Windows' /// Animation effects turned off) or not, and Preferences ▸ Animation effects as given: how far /// page 1 has moved up in each frame, or with ⌘/Ctrl held, the view's zoom. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad337a87e8..0a38160463 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -854,8 +854,8 @@ milestone = "M3" status = "shipped" commands = ["view.continuous"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past"] -notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"] +notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] id = "view.layout-single-page" From 1ae12090d00e2da9cb6951c3619f579e6ea90dfe Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:06 +0800 Subject: [PATCH 063/207] fix(redact): the tail of a longer number is not a phone number (#1884) (#1911) The phone pattern tried every position, so the last groups of a French social security number, an IBAN or a SIRET matched as a trunk-0 or NANP number once the groups before them were skipped, and the start of the identifier stayed visible. A phone number is now a whole run of digit groups: one reached through separators from a digit before it, or going on past the 28-character cap, is not one. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/redact/src/patterns.rs | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 0af3b02a95..18fc65e339 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -68,18 +68,41 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize, (i, n) } +/// Whether the separated digit run goes on past `k` in the direction `step` (1 or −1): the next +/// digit after `k` is reached through separators only. +fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool { + let mut j = k; + loop { + let Some(c) = j.checked_add_signed(step).and_then(|j| s.get(j)) else { return false }; + if c.is_ascii_digit() { + return true; + } + if !seps.contains(c) { + return false; + } + j = j.wrapping_add_signed(step); + } +} + fn phone(s: &[char], i: usize) -> Option { + const SEPS: [char; 5] = [' ', '-', '.', '(', ')']; let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit(); if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') { return None; } + // A phone number is a whole run of digit groups. The tail of a longer one (an IBAN, a + // company or social security number) is not a phone number, however it is grouped (#1884). + if run_continues(s, i, -1, &SEPS) { + return None; + } let plus = s[i] == '+'; let first = if plus { i + 1 } else { i }; - let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 28); + let (end, n) = digit_run(s, first, &SEPS, 28); let run = s.get(first..end)?; let seps = run.iter().filter(|c| !c.is_ascii_digit()).count(); - // Always grouped by at least one separator, so plain numbers never match. - if seps == 0 || digit(s.get(end)) { + // Always grouped by at least one separator, so plain numbers never match; nor does the + // head of a run too long to be a phone number. + if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) { return None; } let ok = if plus { @@ -358,6 +381,18 @@ mod tests { ); } + #[test] + fn the_tail_of_a_longer_number_is_not_a_phone_number() { + // #1884: the last groups of a French social security number, an IBAN and a SIRET used + // to be marked as phone numbers (and the start left visible). Example numbers. + for s in ["NIR 1 85 05 78 006 084 36 end", "IBAN FR76 3000 6000 0112 3456 7890 189", "SIRET 123 456 782 00010"] { + assert_eq!(found(Pattern::Phone, s), Vec::::new(), "{s}"); + } + assert_eq!(found(Pattern::Phone, "tel 01 23 45 67 89 / +33 1 23 45 67 89"), ["01 23 45 67 89", "+33 1 23 45 67 89"]); + // Nor the head of a run too long to be one: 28 characters in, this one still goes on. + assert_eq!(found(Pattern::Phone, "01 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7"), Vec::::new()); + } + #[test] fn emails() { assert_eq!( From 824837915205ee9bd19de641ee6597d0b8adf2c2 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:16 +0800 Subject: [PATCH 064/207] fix(forms): new text and date fields take Acrobat's Auto font size (#1888) (#1912) add_field gave text and date fields a fixed /Helv 12 Tf, which is clipped in a box shorter than 12 pt, and a script can't see the clipping. They now get /Helv 0 Tf (Auto), as Acrobat's new text fields do: 12 pt where there is room, smaller where there isn't. An explicit size set afterwards still wins; choice fields keep 12 pt. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/forms/src/author.rs | 10 ++++++---- crates/forms/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 6d49be1001..0b60506c76 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1,8 +1,9 @@ //! Prepare a form: create, change and delete fields (execution plan M6.6). //! //! New fields follow Acrobat's conventions: names "Text1", "Check Box1", "Group1" (radio groups), -//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; 12 pt Helvetica (auto-size for -//! check boxes), a thin grey border and a white background, and appearance streams generated +//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; Helvetica at Acrobat's Auto size +//! for text and date fields (12 pt in a box that has room, smaller in a short one), 12 pt in +//! choice fields, a thin grey border and a white background, and appearance streams generated //! immediately so every viewer shows the empty field. use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -545,14 +546,15 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel match kind { NewField::Text { multiline } => { w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); if *multiline { w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); } } NewField::Date => { w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). let js = |s: &str| { let mut a = Dict::new(); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0256bfafb9..d434957eb0 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -895,6 +895,34 @@ fn formats_validation_and_calculations_run_like_acrobat() { assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); } +#[test] +fn new_text_fields_fit_their_box() { + // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's + // Auto size: 12 pt where there is room, smaller where there isn't. + let mut doc = one_page(); + let tf_size = |ap: &str| { + ap.split("/Helv ") + .nth(1) + .and_then(|s| s.split(' ').next()) + .and_then(|n| n.parse::().ok()) + .unwrap_or_else(|| panic!("no /Helv size in {ap}")) + }; + for (kind, name, value) in [(NewField::Text { multiline: false }, "short", "Hello world"), (NewField::Date, "date", "10/13/2026")] { + add_field(&mut doc, 0, [20.0, 20.0, 200.0, 28.0], &kind, Some(name)).unwrap(); + set_value(&mut doc, name, &FieldValue::Text(value.into())).unwrap(); + let f = field(&fields(&doc), name).clone(); + assert_eq!(f.da, "/Helv 0 Tf 0 g", "{name}"); + let size = tf_size(&ap(&doc, &f.widgets[0])); + assert!(size > 0.0 && size <= 8.0, "{name}: {size} pt in an 8 pt box"); + } + add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); + assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // An explicit size still wins. + set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); + assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); +} + #[test] fn appearance_properties_restyle_every_widget() { let mut doc = one_page(); From 668f491771497efc4fdf9c7e0da58969563b93f2 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:26 +0800 Subject: [PATCH 065/207] feat(ui): a middle click closes a tab (#1853) (#1920) As in Acrobat and browsers. A document tab with unsaved changes asks first, through the same close request as its close button; the Combine files tab closes too. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/chrome.rs | 7 ++++++- crates/ui-egui/tests/ui.rs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f4bfa22c35..9e376e373d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -170,6 +170,11 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if response.clicked() { app.active = Some(i); } + // A middle click closes the tab, as in Acrobat and browsers (#1853); + // unsaved changes ask first, as the close button does. + if response.middle_clicked() { + close = Some(i); + } } // Dragging a tab moves it (#1537): a line marks the gap it will land // in, and releasing the button drops it there. @@ -222,7 +227,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if response.clicked() { app.open_combine_tab(); } - if close.is_some() { + if close.is_some() || response.middle_clicked() { app.close_combine_tab(); } } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 4c1bdffbec..b45e7775b8 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -270,6 +270,28 @@ fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() { h.get_by_label(long); } +/// A middle click on a tab closes it, as in Acrobat and browsers (#1853); a tab with unsaved +/// changes asks first, as its close button does. +#[test] +fn a_middle_click_closes_the_tab() { + let mut h = harness(|app| { + app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap(); + app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap(); + }); + h.get_by_label("first.pdf").click_button(egui::PointerButton::Middle); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1); + assert_eq!(app.session.get(app.views[0].id).unwrap().name, "second.pdf"); + assert!(app.close_request.is_none(), "a clean document closes without asking"); + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + h.run_steps(2); + h.get_by_label("second.pdf (edited)").click_button(egui::PointerButton::Middle); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1, "unsaved changes ask first"); + assert!(h.state().close_request.is_some()); +} + #[test] fn overflowing_tabs_can_be_reached_by_scrolling() { let mut h = harness(|app| { From af62daeaf8914dbf8ef41cbcfe6b974597e1b6f8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:36 +0800 Subject: [PATCH 066/207] fix(organize): bound how deep an import follows the object graph (#1800) (#1921) The page Copier recursed once per reference and once per nested array or dictionary with no bound, so a source whose page reached a chain of thousands of shallow indirect objects overflowed the stack in Insert Pages, Combine, Extract, Split and Duplicate; the memo map only cut cycles. The copy now keeps a depth of at most 256, refuses with an OrganizeError beyond it, and import_pages_with frees the objects a refused copy added, so the destination is as it was. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/organize/src/import.rs | 138 ++++++++++++++++++++-------------- crates/organize/src/pdfx.rs | 2 +- crates/organize/src/tests.rs | 40 ++++++++++ 3 files changed, 123 insertions(+), 57 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index c420d2d59c..4527aab690 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -28,8 +28,16 @@ use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk}; const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; +/// How deep a copy follows references and nested arrays and dictionaries, counted together. +/// Real documents stay well under this (a page's resources, fonts and descriptors, nested +/// form XObjects and field trees are a few levels each); a chain of thousands of indirect +/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). +const MAX_COPY_DEPTH: usize = 256; + struct Copier<'a> { src: &'a Document, + /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. + depth: usize, map: HashMap, /// Source page → destination page, for rewriting `/P` and destinations. pages: HashMap, @@ -41,11 +49,15 @@ struct Copier<'a> { ocgs: Vec<(ObjRef, ObjRef)>, } -impl Copier<'_> { +impl<'a> Copier<'a> { + fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + } + /// Copy an indirect object (once), returning its destination reference. - fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> ObjRef { + fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { if let Some(d) = self.map.get(&r) { - return *d; + return Ok(*d); } // Reserve the number first so cycles (field ↔ widget) terminate. let new = dst.add(Object::Null); @@ -54,26 +66,41 @@ impl Copier<'_> { if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { self.ocgs.push((r, new)); } - let copied = self.copy_value(dst, &obj, Some(new)); + let copied = self.copy_value(dst, &obj, Some(new))?; dst.set(new, copied); - new + Ok(new) } - fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Object { - match o { - Object::Ref(r) => Object::Ref(self.copy_ref(dst, *r)), - Object::Array(a) => Object::Array(a.iter().map(|x| self.copy_value(dst, x, None)).collect()), - Object::Dict(d) => Object::Dict(self.copy_dict(dst, d, this)), - Object::Stream(s) => { - let mut s = s.clone(); - s.dict = self.copy_dict(dst, &s.dict, None); - Object::Stream(s) - } - other => other.clone(), + /// One level deeper into the object graph, or the refusal when there are too many. + fn descend(&mut self) -> Result<(), OrganizeError> { + if self.depth >= MAX_COPY_DEPTH { + return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); } + self.depth += 1; + Ok(()) } - fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Dict { + fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { + return Ok(o.clone()); + } + self.descend()?; + let copied = match o { + Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), + Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), + Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { + let mut s = s.clone(); + s.dict = dict; + Object::Stream(s) + }), + other => Ok(other.clone()), + }; + self.depth = self.depth.saturating_sub(1); + copied + } + + fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); let mut out = Dict::new(); @@ -86,7 +113,7 @@ impl Copier<'_> { out.set(k.clone(), v.clone()); continue; } - out.set(k.clone(), self.copy_value(dst, v, None)); + out.set(k.clone(), self.copy_value(dst, v, None)?); } if let Some(this) = this { if is_annot { @@ -97,7 +124,7 @@ impl Copier<'_> { self.fields.push(this); } } - out + Ok(out) } /// Map a source destination (explicit array or reference to one) to the destination @@ -162,15 +189,8 @@ impl Copier<'_> { } } else { // Other actions (URI, Launch, JavaScript…) carry no page references. - let mut c = Copier { - src: self.src, - map: self.map.clone(), - pages: HashMap::new(), - annots: Vec::new(), - fields: Vec::new(), - ocgs: Vec::new(), - }; - let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); + let mut c = Copier::new(self.src, self.map.clone(), HashMap::new()); + let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?; d.set(b"A".to_vec(), copied); } } @@ -183,9 +203,8 @@ impl Copier<'_> { /// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so /// an object the copied pages already brought along (an ICC profile) is not stored twice. -pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Object { - let mut c = Copier { src, map, pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; - c.copy_value(dst, o, None) +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { + Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) } /// How colliding fully-qualified field names are resolved when pages are imported. @@ -222,7 +241,17 @@ pub fn import_pages_with( conflicts: FieldConflicts, ) -> Result { let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); - let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?; + let imported = match import_pages_mapped(dst, src, src_pages, at, conflicts) { + Ok(imported) => imported, + Err(e) => { + // A refusal while copying (a source too deep to follow) has only added objects: + // take them back, so the destination is as it was. + for n in dst.object_numbers().into_iter().filter(|n| *n >= first_new) { + dst.free(ObjRef::new(n, dst.generation(n))); + } + return Err(e); + } + }; let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); crate::dedupe::dedupe_resources(dst, &created, true); Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields }) @@ -268,7 +297,7 @@ fn import_pages_mapped( } let mut existing = walk(dst)?; let root = pages_root(dst)?; - let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); // Allocate every destination page first, so destinations between copied pages resolve. let targets: Vec<(ObjRef, ObjRef, Dict)> = src_pages .iter() @@ -291,14 +320,7 @@ fn import_pages_mapped( // popup, a widget's place). Destinations still map to the pages copied. let repeat = done.contains_key(&page); let new = if repeat { dst.add(Object::Null) } else { new }; - let mut own = repeat.then(|| Copier { - src, - map: HashMap::new(), - pages: copier.pages.clone(), - annots: Vec::new(), - fields: Vec::new(), - ocgs: Vec::new(), - }); + let mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone())); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); @@ -321,7 +343,7 @@ fn import_pages_mapped( && let Some(copied) = copier.map.get(&parent).copied() { let r = dst.add(Object::Null); - let c = copier.copy_value(dst, &resolved, Some(r)); + let c = copier.copy_value(dst, &resolved, Some(r))?; dst.set(r, c); // `/Kids` may be an array object of its own: add to it there, so the // field keeps its other widgets. @@ -348,10 +370,10 @@ fn import_pages_mapped( // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. let r = match a { - Object::Ref(r) => annots.copy_ref(dst, r), + Object::Ref(r) => annots.copy_ref(dst, r)?, Object::Dict(_) => { let r = dst.add(Object::Null); - let c = annots.copy_value(dst, &a, Some(r)); + let c = annots.copy_value(dst, &a, Some(r))?; dst.set(r, c); r } @@ -363,17 +385,17 @@ fn import_pages_mapped( d.set(b"Annots".to_vec(), Object::Array(out)); continue; } - d.set(k.clone(), copier.copy_value(dst, v, None)); + d.set(k.clone(), copier.copy_value(dst, v, None)?); } if let Some(r) = resources { - let r = copier.copy_value(dst, &r, None); + let r = copier.copy_value(dst, &r, None)?; d.set(b"Resources".to_vec(), r); } for k in INHERITABLE { if !d.contains(k) && let Some(v) = inherited.get(k) { - let v = copier.copy_value(dst, v, None); + let v = copier.copy_value(dst, v, None)?; d.set(k.to_vec(), v); } } @@ -936,7 +958,7 @@ impl Combiner { let outline = source_outline(src, imported.page_map); self.marks.push(Mark { title: title.to_string(), page: *first, outline }); } - collect_attachments(&mut self.out, src, &mut self.attachments); + collect_attachments(&mut self.out, src, &mut self.attachments)?; let declared = crate::pdfx::PrintStandard::of(src); match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { Some(same) => self.agreed &= same, @@ -1030,7 +1052,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu if let Some(first) = imported.pages.first() { marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); } - collect_attachments(&mut out, file.src, &mut attachments); + collect_attachments(&mut out, file.src, &mut attachments)?; let declared = crate::pdfx::PrintStandard::of(file.src); match &standard { None => standard = Some((declared, file.src, imported.objects)), @@ -1056,22 +1078,23 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu } /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. -fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) { +fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { let Some(tree) = src .root() .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) else { - return; + return Ok(()); }; let mut entries = Vec::new(); name_tree_entries(src, &tree, 0, &mut entries); - let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); for (k, v) in entries { - let v = copier.copy_value(dst, &v, None); + let v = copier.copy_value(dst, &v, None)?; out.push((k, v)); } + Ok(()) } /// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… @@ -1137,7 +1160,7 @@ fn source_outline(src: &Document, pages: HashMap) -> Vec Result<( } } } - let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; - let resources = d.get(b"Resources").map(|r| copier.copy_value(dst, r, None)).unwrap_or(Object::Dict(Dict::new())); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + let resources = match d.get(b"Resources") { + Some(r) => copier.copy_value(dst, r, None)?, + None => Object::Dict(Dict::new()), + }; register_layers(dst, src, &copier.ocgs)?; let mut fd = Dict::new(); fd.set(b"Type".to_vec(), Object::name("XObject")); diff --git a/crates/organize/src/pdfx.rs b/crates/organize/src/pdfx.rs index 1aa4fc503c..501d969bec 100644 --- a/crates/organize/src/pdfx.rs +++ b/crates/organize/src/pdfx.rs @@ -93,7 +93,7 @@ pub(crate) fn carry(dst: &mut Document, src: Option<&Document>, standard: &Print let root = dst.root().ok_or(OrganizeError::NoPageTree)?; if let Some(intents) = &standard.intents { let src = src.ok_or_else(|| OrganizeError::Invalid("the output intents' source document was not kept".into()))?; - let copied = crate::import::copy_object(dst, src, intents, map); + let copied = crate::import::copy_object(dst, src, intents, map)?; dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?; } let Some(version) = &standard.version else { return Ok(()) }; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 9f493e296f..7b271dc6c4 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2444,3 +2444,43 @@ fn combine_field_conflicts_previews_each_file_in_order() { assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]); assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename"); } + +/// #1800: thousands of shallow objects, each referring to the next, used to recurse once per +/// object and exhaust the stack. The copy is refused instead, and the destination is as it was; +/// a chain within the budget and a short cycle copy as before. +#[test] +fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alone() { + fn chained(tail: &[String]) -> Document { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(), + body("C1"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + b.extend_from_slice(tail); + open(build(&b, "/Root 1 0 R")) + } + fn chain(n: usize) -> Document { + let mut tail: Vec = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect(); + tail.push("<< /End true >>".into()); + chained(&tail) + } + // On a thread with less stack than any platform's main thread, so the budget is proven. + let run = std::thread::Builder::new() + .stack_size(512 << 10) + .spawn(|| { + let mut dst = open(fixture()); + let before = (labels(&dst), dst.object_numbers()); + let err = import_pages(&mut dst, &chain(20_000), &[0], 0).unwrap_err(); + assert!(matches!(&err, OrganizeError::Invalid(m) if m.contains("levels deep")), "{err:?}"); + assert_eq!((labels(&dst), dst.object_numbers()), before, "the destination is as it was"); + import_pages(&mut dst, &chain(100), &[0], 0).unwrap(); + let cycle = chained(&["<< /Next 7 0 R >>".into(), "<< /Next 6 0 R >>".into()]); + import_pages(&mut dst, &cycle, &[0], 0).unwrap(); + assert_eq!(labels(&dst)[..3], ["C1", "C1", "Page 1"]); + }) + .unwrap() + .join(); + assert!(run.is_ok(), "the copy must not overflow the stack"); +} From c948d09c6a5ffc6b36221b1142c6c2205ee544f5 Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 08:29:46 +0200 Subject: [PATCH 067/207] M2: bound page-tree traversal memory for repeated children (#1926) Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> --- crates/model/README.md | 4 + crates/model/src/lib.rs | 187 ++++++++++++++++++++++++++++++++-------- 2 files changed, 155 insertions(+), 36 deletions(-) diff --git a/crates/model/README.md b/crates/model/README.md index 5872b25665..bfc6e42e3b 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -14,5 +14,9 @@ L3/L4 crates need: `view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a comment's picture upright inside its user-space `/Rect` on a turned page. +The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. +Repeated child references are skipped before copying attributes, with first depth-first +occurrence order preserved. Returned page dictionaries still own their effective attributes. + `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 211b44492b..38bf36f222 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -84,44 +84,97 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep /// trees are cut off rather than followed. pub fn pages(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(top) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut stack: Vec<(ObjRef, Dict)> = vec![(top, Dict::new())]; - while let Some((node, inherited)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - let mut attrs = inherited.clone(); - for k in INHERITABLE { - if let Some(v) = d.get(k) { - attrs.set(k.to_vec(), v.clone()); - } - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for k in kids.iter().rev() { - if let Some(r) = k.as_ref() { - stack.push((r, attrs.clone())); - } - } - } else { - let mut dict = d.clone(); - for k in INHERITABLE { - if !dict.contains(k) - && let Some(v) = attrs.get(k) - { - dict.set(k.to_vec(), v.clone()); - } - } - out.push(Page { obj: node, dict }); + PageTree::new(doc).collect() +} + +// Retain the owner of each inherited value rather than deep-cloning direct Resources on +// every edge. Only the final Page dictionaries need owned copies of those values. +type Inherited = [Option>; 4]; + +struct Children { + owner: std::sync::Arc, + next: usize, + inherited: Inherited, +} + +impl Children { + fn kids(&self) -> Option<&Vec> { + match &*self.owner { + Object::Dict(d) => d.get(b"Kids")?.as_array(), + other => other.as_array(), } } - out +} + +struct PageTree<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + // One cursor per ancestor, never one entry per child. This also preserves the first + // depth-first occurrence's attributes when malformed trees share a child. + pending: Vec, +} + +impl<'a> PageTree<'a> { + fn new(doc: &'a Document) -> Self { + let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); + let pending = top + .into_iter() + .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) + .collect(); + Self { doc, seen: Default::default(), pending } + } +} + +impl Iterator for PageTree<'_> { + type Item = Page; + + fn next(&mut self) -> Option { + while let Some(parent) = self.pending.last_mut() { + let Some(child) = parent.kids().and_then(|kids| kids.get(parent.next)) else { + self.pending.pop(); + continue; + }; + let node = child.as_ref(); + // A successful get means next is strictly below the allocated array length. + parent.next += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + let mut inherited = parent.inherited.clone(); + for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { + if d.contains(key) { + *owner = Some(obj.clone()); + } + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let owner = match d.get(b"Kids") { + Some(Object::Ref(r)) => self.doc.get(*r), + Some(Object::Array(_)) => obj.clone(), + _ => continue, + }; + self.pending.push(Children { owner, next: 0, inherited }); + } else { + let mut dict = d.clone(); + for (key, owner) in INHERITABLE.iter().zip(&inherited) { + if !dict.contains(key) + && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + { + dict.set(key.to_vec(), value.clone()); + } + } + return Some(Page { obj: node, dict }); + } + } + None + } } #[cfg(test)] @@ -156,6 +209,68 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn repeated_children_do_not_queue_resource_copies() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = ObjRef { num: 3, generation: 0 }; + d.update_dict(root, |dict| { + // Modest fixture: the assertion measures retained work, not an OOM or timing. + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(leaf); 128])); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 1024])); + dict.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let mut walk = PageTree::new(&d); + let p = walk.next().unwrap(); + assert_eq!(p.obj, leaf); + assert_eq!(p.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"Payload").unwrap().as_array().unwrap().len(), 1024); + assert!(walk.pending.len() <= 2, "duplicate edges retained {} pending entries", walk.pending.len()); + assert!(walk.next().is_none()); + } + + #[test] + fn shared_children_keep_depth_first_order_and_nearest_attributes() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = ObjRef { num: 3, generation: 0 }; + let mut second = d.get(leaf).as_dict().unwrap().clone(); + second.set(b"Rotate".to_vec(), Object::Int(180)); + let second = d.add(second); + let mut branch = Dict::new(); + branch.set(b"Type".to_vec(), Object::name("Pages")); + branch.set(b"Rotate".to_vec(), Object::Int(90)); + // A cycle, a shared child, an invalid entry, and an indirect Kids array. + let kids = d.add(Object::Array(vec![Object::Ref(root), Object::Null, Object::Ref(leaf), Object::Ref(second)])); + branch.set(b"Kids".to_vec(), Object::Ref(kids)); + let branch = d.add(branch); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(branch), Object::Ref(leaf), Object::Ref(second)])); + }) + .unwrap(); + let pages = pages(&d); + assert_eq!(pages.iter().map(|p| p.obj).collect::>(), vec![leaf, second]); + assert_eq!(pages[0].rotation(&d), 90, "first depth-first occurrence supplies inheritance"); + assert_eq!(pages[1].rotation(&d), 180, "a leaf's own attribute wins"); + assert!(pages.iter().all(|p| p.dict.contains(b"Resources"))); + } + + #[test] + fn wide_page_tree_retains_only_ancestor_cursors() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec = (0..64).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + d.update_dict(root, |dict| dict.set(b"Kids".to_vec(), Object::Array(kids.clone()))).unwrap(); + let mut walk = PageTree::new(&d); + for kid in kids { + assert_eq!(walk.next().unwrap().obj, kid.as_ref().unwrap()); + assert!(walk.pending.len() <= 2); + } + assert!(walk.next().is_none()); + } + fn apply(m: [f64; 6], x: f64, y: f64) -> [f64; 2] { [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]] } From 9caddc6e9b82e4cb4125fce6f8af4934b7f6f22c Mon Sep 17 00:00:00 2001 From: regendorf-ux Date: Thu, 15 Oct 2026 02:30:08 -0400 Subject: [PATCH 068/207] Compare files: side by side, scrolled and zoomed together (#1927) * Compare files: side by side, scrolled and zoomed together After Compare files, when the old version is open in a tab and the window is at least 1000 px wide, the document area splits: the old file on the left, the new on the right, each a page view with its own shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half under the pointer leads; the other takes its zoom, fit, layout and rotation, and its scroll offset on the next frame. A change clicked in the panel jumps both halves; Side by side / New file only toggle the mode; Clear ends it. The old file keeps rendering while shown. Rows in every language, parity misc.compare-side-by-side shipped. Co-Authored-By: Claude Opus 5.5 * ATTRIBUTION.toml: the language files' checksums after the new rows cargo xtask assets checks each catalog's sha256; the rows this branch adds changed the files without updating it. Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 42 ++++----- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/compare_ui.rs | 140 +++++++++++++++++++++++++++- crates/ui-egui/src/docking.rs | 5 +- crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/kk.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/lib.rs | 23 +++-- crates/ui-egui/tests/compare.rs | 18 ++++ parity/acrobat-features.toml | 8 +- 28 files changed, 294 insertions(+), 34 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 5734c03954..290dea2ccf 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "0ef505ad4ae53d404d4474d2881f54bc3692739fe9200692fad2b3ace9036a03" +sha256 = "a4ab30e3f0e7e65c93dadf7c4dd88b15efbad5ae1f218d77f639cf490c900bdb" # 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 = "73efe98c3a4190715f3f76d714390396887f45d2092926729832b79a194488c0" +sha256 = "3ed54e2ed759fbcf7fbff39a70820de78a7e79ce0cd8133e0d870ad0f4b62ac3" [[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 = "e0959af2ee8e317372d9076e487e41d025015460b99906482efa1a0e528802c3" +sha256 = "74ce914c682d54480f986a007ae4140ec19bdfa2ac9729418f04a8779a98330b" [[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 = "0a327af10b0bb916ef1b912e6797214bd0b2c18c1e0ef27d9f7a92a3cbeac5b7" +sha256 = "06bb8f0ce964ac41355c589ba278c941b281aca249741672d513ca2033bd818d" [[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 = "c2cf9490a9f9c828382e2f24df3eaea511b4dfab6543ca00ac61e0014029f051" +sha256 = "615c578b6da67dc5c23fe6f290464aabb327da68027bd7ebd4e065a61f047b4b" [[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 = "fccce321ccb64bdfcb75e1d6d09f661fb6a738b30cc0ec1133134ea91959e3ad" +sha256 = "c7156ba4a06220d697c908e173f6bbd9ef1475069f90dcb8f72bc5a620e51cb3" [[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 = "64722973eaf542884ddd170dd9541649c1b1b365ad8c63d0d2864ffbecd20941" +sha256 = "8286c0e834e0a87fc1909dba2d974aa8993230e5cdb9ce89abdab3cd1ecf0d22" [[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 = "0475c03f0b2190998e247d6996d5473d3fd85ea6c55e582b7159b42096c63f9e" +sha256 = "8b65824b11221ec61fc42a32093d61f301a57e206f69e166c491f26333cd593f" [[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 = "6423cc0904fd4e49af25121d9fe4d67d09d684f45acbb8165d4ea7a474a3275c" +sha256 = "85eb2eb9f43b773fe76747b51d288a590a1d5251b19c189e818c38bc88a5781c" [[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 = "588adcef83d5ec8e9a1e1fac093afa6afee30d94ac68e8b428ebd487cdc23c3e" +sha256 = "32c0f3141dab1aa960690e38a9414b88252a8477e9dd9c19e6b21a7dd099c6b0" [[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 = "9fda2deb8db4e919199ae032b9a9a220f32896462640578cd155074809e71f0d" +sha256 = "8b47ba586f779b4fd42d483b52a81260e52e10c4135fe541f6a62148f9b64812" [[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 = "b60244d234bcec0602a10a077167861d60c86ab3a364c3dfc4b4e0466dd54872" +sha256 = "0a9864a30413a28ba90c11eb4b2760686adf0cb7d060365c2936a5e0c1201009" [[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 = "18416c5dc7b28bfb999e1f8127337d37915c67a91d8b8b3fc52e3286a11205ba" +sha256 = "09e2c640908567e8a56ec9ac857ad5fb1e09d4c3ad68b1ad873f098a55396bb5" [[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 = "e50991d9d81a9a45e78c719c790029486e4c03045c30b10b4fee349fcc6702d0" +sha256 = "7d36195963293e514d9a40242a8fd2aaed479fa4642fed13f9648916a5b1fde1" [[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 = "73fb4046cd4fdccd8c1da4abfab90ecd0ca2395fc9a6302dd00dd4f810a14418" +sha256 = "9c42561f5279e1e5c14b23e35328c69d800750ade22981e9524fc748412328bd" [[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 = "e090ca970a518bbf667959181167ff8f8058b78e4239c0c62654c58450738a99" +sha256 = "64bba70d41277aeba09c1af425347e67586aa52d5d7758148c679af208a6a63b" [[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 = "a5e4cbf2fd2b1f840d31482a090a68fe58dbbd03e2187deecd1398d091c9fb8f" +sha256 = "f055211d06eee77906ddea5e53c7f29c7b110ee58a031a8359a4ce7485edfe46" [[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 = "036dfc2952a758352933ec9ab7f1668a699ed108c8902340566f149df4bc0b79" +sha256 = "eecf66414e14f29b989e7b6de748810609002dc62b71e7789a2eb2daed5ec845" [[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 = "d4ac259fc9dad897c9ca8a55eb830d2027f3387eafd641744dd7b9d43a5d1cb4" +sha256 = "34ce3af08c6afc0bbc3ec37ec1923aa4f8a51db44b4f08d6b45674e91cd1bed4" [[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 = "b0e3dacf8ce437663e3cb9946ced5b9f3cde6a62a2bf5ff8dd3d45268b264bf5" +sha256 = "9d76dc284dd281e3d7a2fb7ea7636ae2c3650519713665f02bf96e8c65d86374" [[asset]] @@ -3363,4 +3363,4 @@ 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 = "eb1f9d2990ab5e177be78d5d5e6d4c3cbdb0961c64df04bbd34e5d9ad5b42a3d" +sha256 = "fb217509688595c77fcf8c5d256adb931a7a21c87c23dcc31bc79ba5710a4ffc" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fc4366ff06..f9e9aeca9e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -491,7 +491,9 @@ pub struct DocView { /// interpret, so a drag would otherwise queue a storm of them (one per frame) while the /// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay /// stretched meanwhile; fresh tiles go out once the pan settles. - last_scroll: Vec2, + pub(crate) last_scroll: Vec2, + /// Side-by-side compare: scroll to here this frame, following the other half. + pub(crate) sync_scroll: Option, pan_hold_until: f64, /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan /// settle holds. Fast pages render straight through, with no added latency. @@ -679,6 +681,7 @@ impl DocView { last_scale: 0.0, sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, + sync_scroll: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), comments: Default::default(), @@ -2326,6 +2329,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if !std::mem::replace(&mut view.shown, true) { scroll = scroll.scroll_offset(Vec2::ZERO); } + if let Some(v) = view.sync_scroll.take() { + scroll = scroll.scroll_offset(v); + } if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { let r = rects[page.min(rects.len() - 1)]; let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); diff --git a/crates/ui-egui/src/compare_ui.rs b/crates/ui-egui/src/compare_ui.rs index e84cc34dc3..f9c1cbcee4 100644 --- a/crates/ui-egui/src/compare_ui.rs +++ b/crates/ui-egui/src/compare_ui.rs @@ -17,6 +17,13 @@ pub struct CompareState { /// Regions that look different (page, user-space box), page n against page n. pub visual: Vec<(usize, [f64; 4])>, pub selected: Option, + /// Side by side: the old file on the left, the new on the right, scrolled and zoomed + /// together. + pub side_by_side: bool, + /// Which half leads the other (the one the pointer was over last): 0 old, 1 new. + pub leader: usize, + /// Each half's scroll offset last frame, to tell which one moved. + pub prev_scroll: [egui::Vec2; 2], } /// Visual differences are shaded orange. @@ -28,6 +35,7 @@ pub enum PanelAction { Mark, Report, Clear, + SideBySide(bool), } pub fn colour(kind: Kind) -> Color32 { @@ -99,6 +107,16 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option, ui.label(crate::i18n::fmt(tl!("{n} {kind}"), &[("n", &s.visual.len().to_string()), ("kind", tl!("Visual"))])); }); ui.add_space(6.0); + // Side by side, or the new file alone. + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Side by side"), s.side_by_side).clicked() { + action = Some(PanelAction::SideBySide(true)); + } + if widgets::pill_button(ui, tl!("New file only"), !s.side_by_side).clicked() { + action = Some(PanelAction::SideBySide(false)); + } + }); + ui.add_space(4.0); ui.horizontal(|ui| { if ui.button(tl!("Mark as comments")).clicked() { action = Some(PanelAction::Mark); @@ -166,8 +184,37 @@ impl PdfCraftApp { self.views[i].compare_marks.push((*p, r, VISUAL)); } } + // The old file's pages too: what was deleted or replaced, and the visual + // differences, so both versions read with their own shading. + if let Some(oi) = self.views.iter().position(|v| v.id == old) { + let mut marks: Vec<(usize, [f32; 4], Color32)> = result + .changes + .iter() + .filter(|ch| ch.kind != Kind::Inserted) + .flat_map(|ch| ch.old.rects.iter().map(move |r| (ch.old.page, r.map(|v| v as f32), colour(ch.kind)))) + .collect(); + for (p, r) in &visual { + let r = r.map(|v| v as f32); + let covered = marks.iter().any(|(mp, m, _)| *mp == *p && m[0] <= r[2] && r[0] <= m[2] && m[1] <= r[3] && r[1] <= m[3]); + if !covered { + marks.push((*p, r, VISUAL)); + } + } + self.views[oi].compare_marks = marks; + } let n = result.changes.len(); - self.compare = Some(crate::compare_ui::CompareState { old, new, result, visual, selected: None }); + // Side by side when the old version is open in a tab and the window has the room. + let side_by_side = self.views.iter().any(|v| v.id == old) && self.ctx.as_ref().is_none_or(|c| c.content_rect().width() >= 1000.0); + self.compare = Some(crate::compare_ui::CompareState { + old, + new, + result, + visual, + selected: None, + side_by_side, + leader: 1, + prev_scroll: [egui::Vec2::ZERO; 2], + }); self.right = Some(RightPanel::Compare); self.notify(if n == 0 { tl!("The text is the same").to_string() @@ -190,9 +237,20 @@ impl PdfCraftApp { let Some(ch) = s.result.changes.get(i) else { return }; let rect = ch.new.rects.first().copied().or(ch.new.near).unwrap_or_default(); let page = ch.new.page; + let old_target = (ch.old.page, ch.old.rects.first().copied().or(ch.old.near).unwrap_or_default()); let v = &mut self.views[index]; v.go_to_page(page); v.flash = Some((page, rect.map(|x| x as f32), 0.0)); + // The old file's half jumps to its side of the change too. + if let Some(oi) = self.views.iter().position(|v| v.id == old) { + let v = &mut self.views[oi]; + v.go_to_page(old_target.0); + v.flash = Some((old_target.0, old_target.1.map(|x| x as f32), 0.0)); + } + } + PanelAction::SideBySide(on) => { + s.side_by_side = on; + s.leader = 1; } PanelAction::Mark => match self.session.mark_differences(old, new) { Ok(n) => { @@ -218,8 +276,88 @@ impl PdfCraftApp { PanelAction::Clear => { self.compare = None; self.views[index].compare_marks.clear(); + if let Some(oi) = self.views.iter().position(|v| v.id == old) { + self.views[oi].compare_marks.clear(); + } self.right = None; } } } } + +/// The two halves of a side-by-side comparison, as view indexes (old, new), when the active +/// document is the new file of a comparison shown side by side and the old file is open too. +pub(crate) fn side_by_side(app: &PdfCraftApp, index: usize) -> Option<(usize, usize)> { + let c = app.compare.as_ref().filter(|c| c.side_by_side)?; + let new = app.views.get(index).filter(|v| v.id == c.new)?; + let old = app.views.iter().position(|v| v.id == c.old)?; + (old != index).then_some((old, app.views.iter().position(|v| v.id == new.id)?)) +} + +/// Side by side: the old file on the left, the new on the right, each a page view of its own +/// with its shading, scrolled and zoomed together. The half the pointer is over leads; the +/// other follows its zoom, layout and scroll offset. +pub(crate) fn compare_area(app: &mut PdfCraftApp, old: usize, new: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let rect = ui.available_rect_before_wrap(); + let gap = 1.0; + let half_w = ((rect.width() - gap) / 2.0).max(1.0); + let caption_h = 22.0; + let left = egui::Rect::from_min_size(rect.min, egui::vec2(half_w, rect.height())); + let right = egui::Rect::from_min_size(egui::pos2(rect.min.x + half_w + gap, rect.min.y), egui::vec2(half_w, rect.height())); + // Who leads: the half under the pointer (remembered), the new file at first. + if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && let Some(c) = app.compare.as_mut() + { + if left.contains(p) { + c.leader = 0; + } else if right.contains(p) { + c.leader = 1; + } + } + let leader = app.compare.as_ref().map_or(1, |c| c.leader); + let (lead, follow) = if leader == 0 { (old, new) } else { (new, old) }; + // The follower takes the leader's zoom and layout before either is drawn. + let (zoom, fit, layout, continuous, rotation) = { + let v = &app.views[lead]; + (v.zoom, v.fit, v.layout, v.continuous, v.rotation) + }; + { + let v = &mut app.views[follow]; + if v.zoom != zoom || v.fit != fit || v.layout != layout || v.continuous != continuous || v.rotation != rotation { + v.zoom = zoom; + v.fit = fit; + v.layout = layout; + v.continuous = continuous; + v.rotation = rotation; + } + } + let names = [old, new].map(|i| app.session.get(app.views[i].id).map(|d| d.name.clone()).unwrap_or_default()); + ui.painter().vline(rect.min.x + half_w, rect.y_range(), egui::Stroke::new(gap, t.border)); + for (k, (r, index)) in [(left, old), (right, new)].into_iter().enumerate() { + let caption = egui::Rect::from_min_size(r.min, egui::vec2(r.width(), caption_h)); + ui.painter().rect_filled(caption, 0.0, t.panel); + let label = crate::i18n::fmt(if k == 0 { tl!("Old file: {name}") } else { tl!("New file: {name}") }, &[("name", &names[k])]); + ui.painter().text( + egui::pos2(caption.min.x + 10.0, caption.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + t.text_muted, + ); + let body = egui::Rect::from_min_max(egui::pos2(r.min.x, r.min.y + caption_h), r.max); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt(("compare-half", k))); + child.set_clip_rect(body); + crate::canvas::document_area(app, index, &mut child); + } + // After both drew: the leader's scroll, when it moved, is the follower's next offset. + let offsets = [app.views[old].last_scroll, app.views[new].last_scroll]; + if let Some(c) = app.compare.as_mut() { + let lead_k = if leader == 0 { 0 } else { 1 }; + if (offsets[lead_k] - c.prev_scroll[lead_k]).length() > 0.5 && (offsets[1 - lead_k] - offsets[lead_k]).length() > 0.5 { + app.views[follow].sync_scroll = Some(offsets[lead_k]); + ui.ctx().request_repaint(); + } + c.prev_scroll = offsets; + } +} diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index fe4c0390df..58fd71a678 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -409,7 +409,10 @@ pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { match app.active { None if app.combine_showing() => crate::combine_ui::page(app, ui), None => crate::home::show(app, ui), - Some(index) => crate::canvas::document_area(app, index, ui), + Some(index) => match crate::compare_ui::side_by_side(app, index) { + Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui), + None => crate::canvas::document_area(app, index, ui), + }, } } Panel::Tools => crate::panels::left_panel(app, ui), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c67df55718..093757cdc9 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2398,3 +2398,7 @@ animation effects Off معطّلة Mobile الهاتف المحمول Merge image fragments دمج أجزاء الصور Optimizing… merging image fragments جارٍ التحسين… دمج أجزاء الصور + Side by side جنبًا إلى جنب + New file only الملف الجديد فقط + Old file: {name} الملف القديم: {name} + New file: {name} الملف الجديد: {name} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 84f2d6eae8..e75a7bc087 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2340,3 +2340,7 @@ animation effects Off Изключени Mobile Мобилни устройства Merge image fragments Обединяване на фрагменти от изображения Optimizing… merging image fragments Оптимизиране… обединяване на фрагменти от изображения + Side by side Един до друг + New file only Само новият файл + Old file: {name} Стар файл: {name} + New file: {name} Нов файл: {name} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3e5e3680c8..6037cf2816 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2451,3 +2451,7 @@ animation effects Off Vypnuto Mobile Mobilní zařízení Merge image fragments Sloučit fragmenty obrázků Optimizing… merging image fragments Optimalizace… slučování fragmentů obrázků + Side by side Vedle sebe + New file only Jen nový soubor + Old file: {name} Starý soubor: {name} + New file: {name} Nový soubor: {name} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d21a24d6b0..ee0a5418cb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2378,3 +2378,7 @@ animation effects Off Aus Mobile Mobilgerät Merge image fragments Bildfragmente zusammenführen Optimizing… merging image fragments Optimieren… Bildfragmente werden zusammengeführt + Side by side Nebeneinander + New file only Nur neue Datei + Old file: {name} Alte Datei: {name} + New file: {name} Neue Datei: {name} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c9680ed94a..679c2e2e98 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2387,3 +2387,7 @@ animation effects Off Ανενεργά Mobile Κινητό Merge image fragments Συγχώνευση τμημάτων εικόνας Optimizing… merging image fragments Βελτιστοποίηση… συγχώνευση τμημάτων εικόνας + Side by side Δίπλα-δίπλα + New file only Μόνο το νέο αρχείο + Old file: {name} Παλιό αρχείο: {name} + New file: {name} Νέο αρχείο: {name} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8c63c182af..3ca46ff114 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2507,3 +2507,7 @@ animation effects Off Desactivados Mobile Móvil Merge image fragments Combinar fragmentos de imagen Optimizing… merging image fragments Optimizando… combinando fragmentos de imagen + Side by side Lado a lado + New file only Solo el archivo nuevo + Old file: {name} Archivo antiguo: {name} + New file: {name} Archivo nuevo: {name} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3d6bcce62f..1408fddf8f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2377,3 +2377,7 @@ animation effects Off Désactivés Mobile Mobile Merge image fragments Fusionner les fragments d’image Optimizing… merging image fragments Optimisation… fusion des fragments d’image + Side by side Côte à côte + New file only Nouveau fichier seul + Old file: {name} Ancien fichier : {name} + New file: {name} Nouveau fichier : {name} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1961246f0e..0ca2ec150a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2376,3 +2376,7 @@ animation effects Off Ki Mobile Mobil Merge image fragments Képtöredékek egyesítése Optimizing… merging image fragments Optimalizálás… képtöredékek egyesítése + Side by side Egymás mellett + New file only Csak az új fájl + Old file: {name} Régi fájl: {name} + New file: {name} Új fájl: {name} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bfed12bf07..d762d9869d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2386,3 +2386,7 @@ animation effects Off Disattivati Mobile Cellulare Merge image fragments Unisci frammenti di immagine Optimizing… merging image fragments Ottimizzazione… unione dei frammenti di immagine + Side by side Affiancati + New file only Solo il nuovo file + Old file: {name} File vecchio: {name} + New file: {name} File nuovo: {name} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2ac86df72d..4a80d277f7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2377,3 +2377,7 @@ animation effects Off オフ Mobile モバイル Merge image fragments 画像の断片を結合 Optimizing… merging image fragments 最適化中… 画像の断片を結合しています + Side by side 並べて表示 + New file only 新しいファイルのみ + Old file: {name} 古いファイル: {name} + New file: {name} 新しいファイル: {name} diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 443b756a9a..67631f3374 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2375,3 +2375,7 @@ animation effects Off Өшірулі Mobile Мобильді құрылғы Merge image fragments Сурет фрагменттерін біріктіру Optimizing… merging image fragments Оңтайландыру… сурет фрагменттерін біріктіру + Side by side Қатар + New file only Тек жаңа файл + Old file: {name} Ескі файл: {name} + New file: {name} Жаңа файл: {name} diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index a658a2c9a3..f67f0bd8c8 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2621,3 +2621,7 @@ key Space Tarpas Mobile Mobilusis Merge image fragments Sujungti vaizdo fragmentus Optimizing… merging image fragments Optimizuojama… jungiami vaizdo fragmentai + Side by side Greta + New file only Tik naujas failas + Old file: {name} Senas failas: {name} + New file: {name} Naujas failas: {name} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1824e17424..dc28911163 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2357,3 +2357,7 @@ animation effects Off Wyłączone Mobile Urządzenie mobilne Merge image fragments Scal fragmenty obrazów Optimizing… merging image fragments Optymalizacja… scalanie fragmentów obrazów + Side by side Obok siebie + New file only Tylko nowy plik + Old file: {name} Stary plik: {name} + New file: {name} Nowy plik: {name} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a6461661c1..fe507b6b45 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2391,3 +2391,7 @@ animation effects Off Desativados Mobile Celular Merge image fragments Mesclar fragmentos de imagem Optimizing… merging image fragments Otimizando… mesclando fragmentos de imagem + Side by side Lado a lado + New file only Somente o arquivo novo + Old file: {name} Arquivo antigo: {name} + New file: {name} Arquivo novo: {name} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 299590f466..d8d23a3adb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2290,3 +2290,7 @@ animation effects Off Выкл Mobile Мобильные устройства Merge image fragments Объединять фрагменты изображений Optimizing… merging image fragments Оптимизация… объединение фрагментов изображений + Side by side Рядом + New file only Только новый файл + Old file: {name} Старый файл: {name} + New file: {name} Новый файл: {name} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 742f77046f..c0bb304593 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2382,3 +2382,7 @@ animation effects Off Izklopljeni Mobile Mobilna naprava Merge image fragments Združi delce slik Optimizing… merging image fragments Optimiziranje… združevanje delcev slik + Side by side Drug ob drugem + New file only Samo nova datoteka + Old file: {name} Stara datoteka: {name} + New file: {name} Nova datoteka: {name} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0079cd69ab..5bd6fa1506 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2290,3 +2290,7 @@ animation effects Off ఆఫ్ Mobile మొబైల్ Merge image fragments చిత్ర శకలాలను విలీనం చేయి Optimizing… merging image fragments ఆప్టిమైజ్ చేస్తోంది… చిత్ర శకలాలను విలీనం చేస్తోంది + Side by side పక్కపక్కన + New file only కొత్త ఫైల్ మాత్రమే + Old file: {name} పాత ఫైల్: {name} + New file: {name} కొత్త ఫైల్: {name} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 320db7fb26..b400033d0b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2373,3 +2373,7 @@ animation effects Off Kapalı Mobile Mobil Merge image fragments Görüntü parçalarını birleştir Optimizing… merging image fragments İyileştiriliyor… görüntü parçaları birleştiriliyor + Side by side Yan yana + New file only Yalnızca yeni dosya + Old file: {name} Eski dosya: {name} + New file: {name} Yeni dosya: {name} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index abb939813e..5dbbf01a61 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2399,3 +2399,7 @@ animation effects Off Вимкнено Mobile Мобільні пристрої Merge image fragments Об’єднувати фрагменти зображень Optimizing… merging image fragments Оптимізація… об’єднання фрагментів зображень + Side by side Поруч + New file only Лише новий файл + Old file: {name} Старий файл: {name} + New file: {name} Новий файл: {name} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 931a422c31..c24a7fea7f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2341,3 +2341,7 @@ animation effects Off 关 Mobile 移动设备 Merge image fragments 合并图像碎片 Optimizing… merging image fragments 正在优化… 正在合并图像碎片 + Side by side 并排 + New file only 仅新文件 + Old file: {name} 旧文件:{name} + New file: {name} 新文件:{name} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c6e9f368da..6aa5270ab3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2331,3 +2331,7 @@ animation effects Off 關 Mobile 行動裝置 Merge image fragments 合併影像片段 Optimizing… merging image fragments 正在最佳化… 正在合併影像片段 + Side by side 並排 + New file only 僅新檔案 + Old file: {name} 舊檔案:{name} + New file: {name} 新檔案:{name} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8aacc7eea0..1c94877814 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2978,7 +2978,10 @@ impl eframe::App for PdfCraftApp { ui, |ui| { if let Some(i) = self.active { - canvas::document_area(self, i, ui); + match compare_ui::side_by_side(self, i) { + Some((old, new)) => compare_ui::compare_area(self, old, new, ui), + None => canvas::document_area(self, i, ui), + } } }, ); @@ -3015,7 +3018,10 @@ impl eframe::App for PdfCraftApp { match self.active { None if self.combine_showing() => combine_ui::page(self, ui), None => home::show(self, ui), - Some(index) => canvas::document_area(self, index, ui), + Some(index) => match compare_ui::side_by_side(self, index) { + Some((old, new)) => compare_ui::compare_area(self, old, new, ui), + None => canvas::document_area(self, index, ui), + }, } } else { docking::show(self, ui); @@ -3036,17 +3042,22 @@ impl PdfCraftApp { self.combine_thumbs_frame(ctx); // Retire hidden documents before admitting the visible document's textures, so the // three cache limits apply to the application, regardless of how many tabs are open. + // Side by side: the comparison's old file renders alongside the active one. + let partner = self.active.and_then(|i| compare_ui::side_by_side(self, i)).map(|(old, _)| old); for (i, view) in self.views.iter_mut().enumerate() { if Some(i) != self.active + && Some(i) != partner && let Some(doc) = self.session.get(view.id) { view.suspend_rendering(&doc.renderer); } } - if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) - && let Some(doc) = self.session.get(view.id) - { - view.finish_render_frame(ctx, &doc.info, &doc.renderer); + for i in self.active.into_iter().chain(partner) { + if let Some(view) = self.views.get_mut(i) + && let Some(doc) = self.session.get(view.id) + { + view.finish_render_frame(ctx, &doc.info, &doc.renderer); + } } } } diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index 089d3b8825..e735668a1b 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -33,6 +33,24 @@ fn compare_two_versions() { let id = h.state().active_ids().unwrap().1; let i = h.state().active_ids().unwrap().0; assert_eq!(h.state().views[i].compare_marks.len(), 2); + // Side by side: the old file's half shades its side of the changes and follows the new + // file's zoom; "New file only" ends it. + assert!(h.state().compare.as_ref().unwrap().side_by_side, "side by side on a wide window"); + let old_i = h.state().views.iter().position(|v| v.id == h.state().compare.as_ref().unwrap().old).unwrap(); + assert_eq!(h.state().views[old_i].compare_marks.len(), 2, "replaced words shaded on the old file too"); + assert!(h.state().views[old_i].flash.is_some(), "the old half jumped to the change"); + h.state_mut().views[i].zoom = 1.5; + h.state_mut().views[i].fit = pdfcraft_ui_egui::canvas::Fit::None; + h.run_steps(3); + assert_eq!((h.state().views[old_i].zoom, h.state().views[old_i].fit), (1.5, pdfcraft_ui_egui::canvas::Fit::None), "the old half follows"); + h.get_by_label("New file only").click(); + h.run_steps(2); + assert!(!h.state().compare.as_ref().unwrap().side_by_side); + h.get_by_label("Side by side").click(); + h.run_steps(2); + assert!(h.state().compare.as_ref().unwrap().side_by_side); + h.get_by_label("New file only").click(); + h.run_steps(2); h.get_by_label("Mark as comments").click(); h.run_steps(3); assert_eq!(h.state().session.get(id).unwrap().info.annotations.len(), 2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0a38160463..dfcdb9aab9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9155,11 +9155,11 @@ title = "Side-by-side compare view with synchronised scrolling" area = "N" tier = "P1" milestone = "M12" -status = "planned" -commands = [] +status = "shipped" +commands = ["doc.compare"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"] +notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes." [[feature]] id = "misc.compare-report" From 3f25d55a96380ffc7ff83bb9ff1eeeaf1c958ed3 Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Thu, 15 Oct 2026 09:30:30 +0300 Subject: [PATCH 069/207] M11: measure every use of an image drawn through deeply nested forms (#1811) (#1933) The effective-resolution walk skipped any form XObject already visited once when it was nested more than four deep, with one seen-set for the whole document. A later, larger use of the same form was never measured, so Reduce File Size and Optimize PDF resampled the image for its smallest use: a 1200 px image drawn 1 in and 8 in wide through six nested forms came out 150 px wide, about 19 ppi on the large copy. - Cycles are detected on the current path of forms, not document-wide. - A form with its own resources drawn again at the same scale (only moved) is skipped, as that can't change any image's resolution; forms without resources draw their caller's images and are always walked. - Repeat walks are capped at 100,000. If the cap or the 12-level depth limit leaves a use unmeasured, every image gets INFINITY: recompressed as the settings say, never resampled. Fixes #1811 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/optimize/README.md | 3 +- crates/optimize/src/images.rs | 154 ++++++++++++++++++++---------- crates/optimize/tests/optimize.rs | 107 +++++++++++++++++++++ parity/acrobat-features.toml | 2 + 4 files changed, 213 insertions(+), 53 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 8f943d41e4..32915af6bc 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -20,7 +20,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: side-by-side tiles, rotated strips, masks. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM - through form XObjects; the smallest over all uses). Colour and grayscale images above a + through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be + measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index e4976a81b6..8cfa82674a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -41,73 +41,123 @@ pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { } } -/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the -/// smaller of the two axes) and descending into form XObjects. -fn walk(doc: &Document, data: &[u8], resources: &Dict, ctm: Matrix, depth: usize, seen: &mut HashSet, out: &mut HashMap) { - if depth > 12 { - return; - } - let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let mut stack = vec![ctm]; - for op in pdfcraft_content::parse(data).ops { - let top = stack.last().copied().unwrap_or(ctm); - match op.op.as_slice() { - b"q" => stack.push(top), - b"Q" if stack.len() > 1 => { - stack.pop(); - } - b"cm" => { - if let Some(m) = Matrix::from_operands(&op.operands) - && let Some(t) = stack.last_mut() - { - *t = m.then(&top); +/// Forms nested deeper than this aren't followed. +const MAX_FORM_DEPTH: usize = 12; +/// Walks of a form already walked once (other scale or caller) before the measurement gives up. +/// Each form's first walk is free: their number is bounded by the file's object count. +const MAX_REWALKS: usize = 100_000; + +/// State of one [`effective_resolutions`] pass. +struct Walk<'a> { + doc: &'a Document, + /// The forms being walked right now, outermost first: a form drawing one of these is a cycle. + path: Vec, + /// Forms with their own resources already walked, by the linear part of their matrix. Drawing + /// one again at the same scale (only moved) can't change any image's resolution. + walked: HashSet<(ObjRef, [u64; 4])>, + /// Forms walked at least once. + seen: HashSet, + rewalks: usize, + /// False once a use may have been missed (depth or work limit). + complete: bool, + out: HashMap, +} + +impl Walk<'_> { + /// Walk one content stream, recording each image's displayed resolution (pixels per inch, the + /// smaller of the two axes) and descending into form XObjects. + fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) { + let doc = self.doc; + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut stack = vec![ctm]; + for op in pdfcraft_content::parse(data).ops { + let top = stack.last().copied().unwrap_or(ctm); + match op.op.as_slice() { + b"q" => stack.push(top), + b"Q" if stack.len() > 1 => { + stack.pop(); } - } - b"Do" => { - let Some(name) = op.name(0) else { continue }; - let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - match s.dict.name(b"Subtype") { - Some(b"Image") => { - let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64); - let [a, b, c, d, ..] = top.0; - let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt()); - if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 { - let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0)); - let e = out.entry(r).or_insert(f64::INFINITY); - *e = e.min(ppi); - } + b"cm" => { + if let Some(m) = Matrix::from_operands(&op.operands) + && let Some(t) = stack.last_mut() + { + *t = m.then(&top); } - Some(b"Form") => { - if !seen.insert(r) && depth > 4 { - continue; - } - let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten(); - let res = - s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| resources.clone()); - let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&top); - walk(doc, &s.decoded().unwrap_or_default(), &res, inner, depth + 1, seen, out); - } - _ => {} } + b"Do" => { + let Some(name) = op.name(0) else { continue }; + let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + match s.dict.name(b"Subtype") { + Some(b"Image") => { + let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64); + let [a, b, c, d, ..] = top.0; + let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt()); + if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 { + let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0)); + let e = self.out.entry(r).or_insert(f64::INFINITY); + *e = e.min(ppi); + } + } + Some(b"Form") => self.form(r, s, resources, top), + _ => {} + } + } + _ => {} } - _ => {} } } + + /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`. + fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) { + if self.path.contains(&r) { + return; + } + if self.path.len() >= MAX_FORM_DEPTH { + self.complete = false; + return; + } + let doc = self.doc; + let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten(); + let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&ctm); + let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + // A form without resources uses its caller's, so the same form can draw other images. + if own.is_some() { + let [a, b, c, d, ..] = inner.0; + if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) { + return; + } + } + if !self.seen.insert(r) { + if self.rewalks >= MAX_REWALKS { + self.complete = false; + return; + } + self.rewalks += 1; + } + self.path.push(r); + self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + self.path.pop(); + } } /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. +/// +/// If forms nest too deeply or are drawn too many times to measure every use, every image is +/// given `INFINITY` (never resampled), since its largest use may be among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut out = HashMap::new(); - let mut seen = HashSet::new(); + let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; for p in pages { let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; let Some(contents) = d.get(b"Contents") else { continue }; let res = page_resources(doc, *p).unwrap_or_default(); - walk(doc, &content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), 0, &mut seen, &mut out); + walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); } - out + if !walk.complete { + walk.out.values_mut().for_each(|v| *v = f64::INFINITY); + } + walk.out } /// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3). diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 4058a026dd..72553f4962 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -821,3 +821,110 @@ fn images_that_do_not_tile_a_picture_are_left_alone() { let (mut off, _) = printed_page(40, |_| false, |_, s| s); unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() }); } + +/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square. +fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { + let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do"); + for _ in 1..levels { + inner = form(doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do"); + } + inner +} + +#[test] +fn a_deeply_nested_form_is_measured_at_its_largest_use() { + // 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled. + let small = "q 72 0 0 72 36 36 cm /F Do Q"; + let large = "q 576 0 0 576 18 100 cm /F Do Q"; + for levels in [1, 6, 12] { + for (pages, label) in [(vec![format!("{small} {large}")], "one page"), (vec![small.into(), large.into()], "two pages")] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let outer = nested_forms(&mut doc, img, levels); + for c in &pages { + page(&mut doc, &[("F", outer)], c); + } + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 150.0, "{levels} levels, {label}"); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200), "{levels} levels, {label}"); + } + } +} + +#[test] +fn a_form_drawing_itself_is_walked_once_per_use() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let f = form(&mut doc, None, "/Im Do q 2 0 0 2 0 0 cm /F Do Q"); + let mut x = xobjects(&[("Im", img)]); + x.set(b"F".to_vec(), Object::Ref(f)); + let mut d = match &*doc.get(f) { + Object::Stream(s) => s.clone(), + _ => unreachable!(), + }; + d.dict.set(b"Resources".to_vec(), Object::Dict(resources(x))); + doc.set(f, Object::Stream(d)); + page(&mut doc, &[("F", f)], "q 72 0 0 72 36 36 cm /F Do Q"); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + // Only the outer use: the cycle isn't followed, and doesn't count as a missed use. + assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 1200.0); +} + +#[test] +fn a_shared_form_without_resources_draws_each_callers_image() { + // G has no /Resources, so its /Im is whichever image the form drawing it names. + let mut doc = Document::new_empty(); + let a = image(&mut doc, 1200, 1200, 3, false, None); + let b = image(&mut doc, 1200, 1200, 3, false, None); + let g = form(&mut doc, None, "/Im Do"); + let mut outers = Vec::new(); + for img in [a, b] { + let mut inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("Im", img), ("G", g)])))), "/G Do"); + for _ in 1..6 { + inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do"); + } + outers.push(inner); + } + page(&mut doc, &[("A", outers[0]), ("B", outers[1])], "q 72 0 0 72 36 36 cm /A Do Q q 576 0 0 576 18 100 cm /B Do Q"); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + let ppi = effective_resolutions(&doc, &refs); + assert_eq!(ppi[&a].round(), 1200.0); + assert_eq!(ppi.get(&b).map(|p| p.round()), Some(150.0)); +} + +#[test] +fn images_are_not_resampled_when_some_uses_could_not_be_measured() { + // Too deep: the 13th form isn't followed, so its use of the image is unknown. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let other = image(&mut doc, 1200, 1200, 3, false, None); + let deep = nested_forms(&mut doc, img, 13); + page( + &mut doc, + &[("F", deep), ("Im", img), ("O", other)], + "q 72 0 0 72 36 36 cm /Im Do Q q 72 0 0 72 200 36 cm /O Do Q q 576 0 0 576 18 100 cm /F Do Q", + ); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + let ppi = effective_resolutions(&doc, &refs); + assert_eq!((ppi[&img], ppi[&other]), (f64::INFINITY, f64::INFINITY)); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200)); + + // Too much work: forms without resources (so nothing can be skipped), each drawing the next + // three times, twelve deep, would be (3^12 - 1) / 2 walks. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let mut names = vec![("Im", img)]; + let mut next = "Im"; + let labels = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]; + for (i, label) in labels.iter().enumerate().rev() { + let s = 1.0 + i as f64 / 100.0; + let f = form(&mut doc, None, &format!("q {s} 0 0 {s} 0 0 cm /{next} Do Q /{next} Do q 1 0 0 1 1 0 cm /{next} Do Q")); + names.push((label, f)); + next = label; + } + page(&mut doc, &names, "q 72 0 0 72 36 36 cm /F1 Do Q"); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dfcdb9aab9..e270a8f211 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7264,6 +7264,8 @@ commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"] notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet" [[feature]] id = "optimize.recompress-images" From 6b7abc61d117278162c173b250a44ac7ee16159d Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:30:42 +0800 Subject: [PATCH 070/207] fix(edit): flattening keeps a widget it cannot draw and an undrawn reply (#1794) (#1934) A widget with no appearance to draw (none stored, or none for its selected state) was removed from the page and the field tree with nothing in its place, so the field's value was lost; a reply kept because it could not be drawn was then deleted anyway as a reply of the flattened comment. Both now stay as they are. A widget whose appearance draws nothing is still removed, as is a reply note's empty appearance with its comment. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/edit/src/flatten.rs | 26 ++++++++++++++---- crates/edit/src/tests.rs | 52 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 8 +++--- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index a3f6c886a2..8202c18d83 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -6,7 +6,8 @@ //! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden //! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened //! comments take their pop-ups and replies with them; flattened widgets are removed from the -//! form's field tree. +//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays +//! as it is, so no comment or field value is lost with nothing in its place. use std::collections::HashSet; @@ -154,6 +155,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); + // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. + let mut undrawn: HashSet = HashSet::new(); // A viewer draws many comments from their dictionary even when the file stores no // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so // flattening keeps a comment that still has ink on screen instead of deleting it. @@ -199,9 +202,18 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,9 +230,13 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result