From b77d57000c22c760f731206f6cf14486d149de72 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Tue, 20 Oct 2026 11:38:32 -0400
Subject: [PATCH 04/18] Release 0.10.1: rebuild with the shared workflow's
exact Linux tarball pick
0.10.0's Arch Linux and Flatpak aarch64 jobs picked up the
pdfcraft-cli tarball (storytold/craft-common-actions#35).
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 72 +++++++++++++++++++++++++++---------------------------
Cargo.toml | 68 +++++++++++++++++++++++++--------------------------
2 files changed, 70 insertions(+), 70 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index d56c63a31f..2399531ef4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3938,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"craft-quit",
"eframe",
@@ -3963,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4035,18 +4035,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-content"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -4060,7 +4060,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4076,7 +4076,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"aes",
"md-5",
@@ -4088,7 +4088,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4103,7 +4103,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -4143,14 +4143,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"flate2",
"proptest",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4182,18 +4182,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-js"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,14 +4206,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4223,7 +4223,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4236,11 +4236,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-organize"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4251,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,7 +4261,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4272,7 +4272,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4299,7 +4299,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4352,11 +4352,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"eframe",
"egui",
@@ -4412,7 +4412,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"image",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
diff --git a/Cargo.toml b/Cargo.toml
index 6a3e2aacd2..b00b726dcb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.10.0"
+version = "0.10.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.10.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.10.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.10.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.10.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.10.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.10.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.10.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.10.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.10.0" }
-pdfcraft-model = { path = "crates/model", version = "0.10.0" }
-pdfcraft-content = { path = "crates/content", version = "0.10.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.10.0" }
-pdfcraft-print = { path = "crates/print", version = "0.10.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.10.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.10.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.10.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.10.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.10.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.10.0" }
-pdfcraft-js = { path = "crates/js", version = "0.10.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.10.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.10.0" }
-pdfcraft-export = { path = "crates/export", version = "0.10.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.10.0" }
-pdfcraft-create = { path = "crates/create", version = "0.10.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.10.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.10.0" }
-pdfcraft-render = { path = "crates/render", version = "0.10.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.10.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.10.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.10.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.10.1" }
+pdfcraft-filters = { path = "crates/filters", version = "0.10.1" }
+pdfcraft-cos = { path = "crates/cos", version = "0.10.1" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" }
+pdfcraft-organize = { path = "crates/organize", version = "0.10.1" }
+pdfcraft-measure = { path = "crates/measure", version = "0.10.1" }
+pdfcraft-annot = { path = "crates/annot", version = "0.10.1" }
+pdfcraft-audit = { path = "crates/audit", version = "0.10.1" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" }
+pdfcraft-model = { path = "crates/model", version = "0.10.1" }
+pdfcraft-content = { path = "crates/content", version = "0.10.1" }
+pdfcraft-redact = { path = "crates/redact", version = "0.10.1" }
+pdfcraft-print = { path = "crates/print", version = "0.10.1" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" }
+pdfcraft-sign = { path = "crates/sign", version = "0.10.1" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" }
+pdfcraft-js = { path = "crates/js", version = "0.10.1" }
+pdfcraft-compare = { path = "crates/compare", version = "0.10.1" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" }
+pdfcraft-export = { path = "crates/export", version = "0.10.1" }
+pdfcraft-edit = { path = "crates/edit", version = "0.10.1" }
+pdfcraft-create = { path = "crates/create", version = "0.10.1" }
+pdfcraft-forms = { path = "crates/forms", version = "0.10.1" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" }
+pdfcraft-render = { path = "crates/render", version = "0.10.1" }
+pdfcraft-engine = { path = "crates/engine", version = "0.10.1" }
+pdfcraft-platform = { path = "crates/platform", version = "0.10.1" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" }
+pdfcraft-automation = { path = "crates/automation", version = "0.10.1", default-features = false }
# pdf
hayro = "0.7.1"
From 22bedbf2af25bd4cdc1c188b5eff6ec3d2d089fb Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Tue, 20 Oct 2026 11:38:59 -0400
Subject: [PATCH 05/18] Skip CodeRabbit reviews on Dependabot PRs (#3692)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].
Co-authored-by: Claude Opus 5.5
---
.coderabbit.yaml | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 .coderabbit.yaml
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000000..2408084cf9
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
From d695bc56545944fe9c7181901c61d0bf1c11c887 Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Tue, 20 Oct 2026 18:17:34 -0600
Subject: [PATCH 06/18] fix(redact): evaluate OCMD visibility during
sanitization (#3522)
* fix(redact): evaluate OCMD visibility during sanitization
Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively.
* fix(redact): preserve OCMD visibility during sanitization
* fix(redact): count hidden XObjects during verification
* test(redact): cover hidden form depth limit
---
crates/redact/src/interp.rs | 251 +++++++++++++++++++--
crates/redact/src/sanitize.rs | 32 ++-
crates/redact/src/tests.rs | 414 ++++++++++++++++++++++++++++++++++
3 files changed, 667 insertions(+), 30 deletions(-)
diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs
index 52ca6c276f..415382b232 100644
--- a/crates/redact/src/interp.rs
+++ b/crates/redact/src/interp.rs
@@ -84,9 +84,19 @@ pub(crate) struct Scope<'a> {
pub report: &'a mut Report,
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
pub hidden_text: Option<[f64; 4]>,
- /// Sanitize: optional content groups (and membership dictionaries) that are off; content
- /// marked with them is removed.
- pub hidden_layers: Vec,
+ /// Sanitize: optional content groups that are off; content marked with them is removed.
+ pub hidden_layers: HashSet,
+ /// Whether this pass is evaluating optional-content rules. An empty off set is meaningful:
+ /// policies such as `/AllOff` can still hide content when every group is on.
+ pub layers_enabled: bool,
+ /// Off groups referenced by optional content that remains visible and therefore still needs
+ /// its original default state after sanitization.
+ pub retained_off_layers: HashSet,
+ /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions.
+ ocmd_cache: HashMap,
+ /// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them
+ /// by the containing XObject so repeated `Do` calls share the same bounded evaluation.
+ xobject_oc_cache: HashMap,
/// Content removed from hidden layers (blocks and XObjects).
pub layer_blocks: usize,
fonts: HashMap, Rc>,
@@ -101,7 +111,11 @@ impl<'a> Scope<'a> {
mode,
report,
hidden_text: None,
- hidden_layers: Vec::new(),
+ hidden_layers: HashSet::new(),
+ layers_enabled: false,
+ retained_off_layers: HashSet::new(),
+ ocmd_cache: HashMap::new(),
+ xobject_oc_cache: HashMap::new(),
layer_blocks: 0,
fonts: HashMap::new(),
used_names: Vec::new(),
@@ -111,20 +125,26 @@ impl<'a> Scope<'a> {
/// Sanitizing visits every form XObject (not only those under a region).
fn everywhere(&self) -> bool {
- self.hidden_text.is_some() || !self.hidden_layers.is_empty()
+ self.hidden_text.is_some() || self.layers_enabled
}
/// Is this optional-content reference (an OCG or OCMD) hidden?
- fn layer_hidden(&self, doc: &Document, o: &Object) -> bool {
- if self.hidden_layers.is_empty() {
+ fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool {
+ if !self.layers_enabled {
return false;
}
- if let Some(r) = o.as_ref()
- && self.hidden_layers.contains(&r)
- {
- return true;
+ let reference = o.as_ref();
+ if let Some(r) = reference {
+ if let Some(hidden) = self.ocmd_cache.get(&r) {
+ return *hidden;
+ }
+ if self.hidden_layers.contains(&r) {
+ self.ocmd_cache.insert(r, true);
+ return true;
+ }
}
- // A membership dictionary (default policy AnyOn): hidden when all its groups are.
+ // Evaluate the policy against the same default configuration used to select hidden
+ // groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
@@ -135,7 +155,188 @@ impl<'a> Scope<'a> {
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
- !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
+ let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
+ let hidden = if let Some(expression) = d.get(b"VE") {
+ let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
+ match visible {
+ Some(visible) => !visible,
+ None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096),
+ }
+ } else if groups.is_empty() {
+ false
+ } else {
+ let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
+ let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
+ let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec()));
+ match (d.get(b"P"), policy.as_deref()) {
+ (None, _) | (Some(_), Some(b"AnyOn")) => !any_on,
+ (Some(_), Some(b"AllOn")) => !all_on,
+ (Some(_), Some(b"AnyOff")) => all_on,
+ (Some(_), Some(b"AllOff")) => any_on,
+ // An invalid policy must not make a candidate hidden block survive sanitization.
+ _ => fail_closed(),
+ }
+ };
+ if !hidden {
+ self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied());
+ if let Some(expression) = d.get(b"VE") {
+ let mut referenced = HashSet::new();
+ if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) {
+ self.retained_off_layers.extend(referenced);
+ } else {
+ // If an expression cannot be fully walked, preserve every off group so that
+ // the content we kept does not change visibility after configuration cleanup.
+ self.retained_off_layers.extend(self.hidden_layers.iter().copied());
+ }
+ }
+ }
+ if let Some(r) = reference {
+ self.ocmd_cache.insert(r, hidden);
+ }
+ hidden
+ }
+
+ fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool {
+ if oc.as_ref().is_some() {
+ return self.layer_hidden(doc, oc);
+ }
+ if let Some(hidden) = self.xobject_oc_cache.get(&xobject) {
+ return *hidden;
+ }
+ let hidden = self.layer_hidden(doc, oc);
+ self.xobject_oc_cache.insert(xobject, hidden);
+ hidden
+ }
+
+ fn collect_hidden_expression_groups(
+ doc: &Document,
+ expression: &Object,
+ hidden_layers: &HashSet,
+ depth: usize,
+ path: &mut HashSet,
+ budget: &mut usize,
+ found: &mut HashSet,
+ ) -> bool {
+ if depth > 64 || *budget == 0 {
+ return false;
+ }
+ *budget -= 1;
+ let reference = expression.as_ref();
+ if let Some(r) = reference
+ && !path.insert(r)
+ {
+ return false;
+ }
+ let resolved = doc.resolve(expression);
+ let complete = match &*resolved {
+ Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
+ if let Some(r) = reference
+ && hidden_layers.contains(&r)
+ {
+ found.insert(r);
+ }
+ reference.is_some()
+ }
+ Object::Array(items) => {
+ let Some(Object::Name(operator)) = items.first() else { return false };
+ let operands = match operator.as_slice() {
+ b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..),
+ b"Not" if items.len() == 2 => items.get(1..),
+ _ => None,
+ };
+ operands.is_some_and(|operands| {
+ operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found))
+ })
+ }
+ _ => false,
+ };
+ if let Some(r) = reference {
+ path.remove(&r);
+ }
+ complete
+ }
+
+ /// Evaluate an optional-content visibility expression (`/VE`) against the default config.
+ /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
+ fn visibility_expression(
+ &self,
+ doc: &Document,
+ expression: &Object,
+ depth: usize,
+ path: &mut HashSet,
+ budget: &mut usize,
+ ) -> Option {
+ if depth > 64 || *budget == 0 {
+ return None;
+ }
+ *budget -= 1;
+ let reference = expression.as_ref();
+ if let Some(r) = reference
+ && !path.insert(r)
+ {
+ return None;
+ }
+ let resolved = doc.resolve(expression);
+ let result = match &*resolved {
+ Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
+ Object::Array(items) if (2..=4097).contains(&items.len()) => {
+ let operator = match items.first() {
+ Some(Object::Name(n)) => n.as_slice(),
+ _ => return None,
+ };
+ match operator {
+ b"And" if items.len() >= 2 => {
+ let mut visible = true;
+ for item in items.iter().skip(1) {
+ visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
+ }
+ Some(visible)
+ }
+ b"Or" if items.len() >= 2 => {
+ let mut visible = false;
+ for item in items.iter().skip(1) {
+ visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
+ }
+ Some(visible)
+ }
+ b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
+ _ => None,
+ }
+ }
+ _ => None,
+ };
+ if let Some(r) = reference {
+ path.remove(&r);
+ }
+ result
+ }
+
+ /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
+ /// syntax, cycles, and exhausted budgets count as a possible hidden reference.
+ fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool {
+ if depth > 64 || *budget == 0 {
+ return true;
+ }
+ *budget -= 1;
+ if let Some(r) = expression.as_ref() {
+ if self.hidden_layers.contains(&r) {
+ return true;
+ }
+ if !path.insert(r) {
+ return true;
+ }
+ }
+ let resolved = doc.resolve(expression);
+ let found = match &*resolved {
+ Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
+ Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
+ Object::Dict(_) => true,
+ _ => false,
+ };
+ if let Some(r) = expression.as_ref() {
+ path.remove(&r);
+ }
+ found
}
fn hits(&self, b: [f64; 4]) -> bool {
@@ -534,7 +735,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// Layer removal also changes enclosing replacement spans, so remember the operators before
// any cleanup, not just the survivors of the layer pass.
let original = parsed.ops.clone();
- if !scope.hidden_layers.is_empty() {
+ if scope.layers_enabled {
let props = res_dict(doc, resources, b"Properties");
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
parsed.ops = kept;
@@ -928,11 +1129,13 @@ fn xobject(
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { return None };
match s.dict.name(b"Subtype") {
- _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => {
+ _ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => {
+ // Verify mode must count hidden XObjects too: scan uses that count to decide
+ // whether the HiddenLayers pass is necessary.
+ scope.layer_blocks += 1;
if scope.mode == Mode::Verify {
return None;
}
- scope.layer_blocks += 1;
out.replaced.push(name);
out.doomed.push(r);
Some(None)
@@ -983,6 +1186,11 @@ fn xobject(
}
if scope.depth >= MAX_DEPTH {
if scope.mode == Mode::Verify {
+ // Sanitization removes a Form it cannot enter, so the verification scan
+ // must count that conservative removal or it may decide to skip the pass.
+ if scope.everywhere() {
+ scope.layer_blocks += 1;
+ }
return None;
}
scope.report.forms_removed += 1;
@@ -998,7 +1206,14 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
- Err(_) => return None,
+ Err(_) => {
+ // Apply drops an undecodable Form while sanitizing. Count it during Verify
+ // so a zero-count preflight cannot leave its potentially hidden bytes behind.
+ if scope.everywhere() {
+ scope.layer_blocks += 1;
+ }
+ return None;
+ }
};
let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned());
let res = own.clone().unwrap_or_else(|| resources.clone());
@@ -1073,7 +1288,7 @@ fn xobject(
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
/// included). Returns the remaining operators and how many blocks went.
-fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) {
+fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec = Vec::new();
diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs
index 1940e376a8..bafa769bd3 100644
--- a/crates/redact/src/sanitize.rs
+++ b/crates/redact/src/sanitize.rs
@@ -138,12 +138,13 @@ fn hidden_layers(doc: &Document) -> Vec {
}
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
-fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result {
+fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> {
let off = if layers { hidden_layers(doc) } else { Vec::new() };
- if !text && off.is_empty() {
- return Ok(0);
+ if !text && !layers {
+ return Ok((0, std::collections::HashSet::new()));
}
let mut total = 0;
+ let mut retained_off_layers = std::collections::HashSet::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed = Vec::new();
let mut retired_properties = Vec::new();
@@ -164,13 +165,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
}
- scope.hidden_layers = off.clone();
+ scope.hidden_layers = off.iter().copied().collect();
+ scope.layers_enabled = layers;
let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY);
if let Some(error) = out.error {
return Err(error.into());
}
let blocks = scope.layer_blocks;
- total += out.residue + blocks + report.glyphs;
+ let glyphs = scope.report.glyphs;
+ let retained_for_page = std::mem::take(&mut scope.retained_off_layers);
+ drop(scope);
+ total += out.residue + blocks + glyphs;
+ retained_off_layers.extend(retained_for_page);
if !write {
continue;
}
@@ -216,7 +222,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
- Ok(total)
+ Ok((total, retained_off_layers))
}
/// How many items each category would remove (categories with nothing are included as 0).
@@ -239,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"),
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
- Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0),
+ Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
Hidden::HiddenLayers => {
let off = hidden_layers(doc).len();
- if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) }
+ off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0)
}
Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)),
Hidden::LinksActionsScripts => {
@@ -274,9 +280,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) {
- content_pass(doc, text, layers, true)?;
- }
+ let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
+ content_pass(doc, text, layers, true)?.1
+ } else {
+ std::collections::HashSet::new()
+ };
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed = |doc: &Document| -> std::collections::HashSet {
@@ -394,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result {
// The off layers leave the configuration (their content is gone already).
if let Some(mut oc) = sub(doc, &cat, b"OCProperties") {
- let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r));
+ let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r));
if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
}
diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs
index da1ecf12ab..8bc5ec1418 100644
--- a/crates/redact/src/tests.rs
+++ b/crates/redact/src/tests.rs
@@ -492,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
}
+#[test]
+fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let mut doc = one_page(
+ b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
+ ],
+ );
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(pdfcraft_cos::Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (
+ b"D".to_vec(),
+ Object::Dict(pdfcraft_cos::Dict::from_iter([
+ (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
+ ])),
+ ),
+ ])),
+ );
+ })
+ .unwrap();
+
+ let before = pdfcraft_edit::text_lines(&doc, 0).unwrap();
+ assert!(before.iter().any(|line| line.text.contains("PUBLIC")));
+ assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap();
+ assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}");
+ assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}");
+ assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET"));
+}
+
+#[test]
+fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let mut doc = one_page(
+ b"/Fm Do",
+ "/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
+ stream(
+ "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>",
+ b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC",
+ ),
+ ],
+ );
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
+}
+
+#[test]
+fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Unused) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(),
+ ],
+ );
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
+}
+
+#[test]
+fn hidden_ve_not_is_sanitized_when_its_group_is_on() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()],
+ );
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
+}
+
+fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document {
+ let membership = match policy {
+ "AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
+ "Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>",
+ _ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
+ };
+ let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") };
+ let xobject = match subtype {
+ "Image" => stream(
+ &format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"),
+ b"PRIVATE_HIDDEN_XOBJECT_IMAGE",
+ ),
+ _ => stream(
+ &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"),
+ b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET",
+ ),
+ };
+ let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()];
+ if object_number == 9 {
+ extras.push(membership.as_bytes().to_vec());
+ }
+ extras.push(xobject);
+ let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras);
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))),
+ ])),
+ );
+ })
+ .unwrap();
+ doc
+}
+
+#[test]
+fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut failures = Vec::new();
+ for subtype in ["Image", "Form"] {
+ for policy in ["AllOff", "Not"] {
+ let mut doc = all_on_ocmd_xobject(subtype, policy);
+ let before = scan(&doc);
+ let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n);
+ if hidden_count == 0 {
+ failures.push(format!("{subtype} /{policy} was not counted: {before:?}"));
+ }
+
+ match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) {
+ Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {}
+ Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")),
+ Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")),
+ }
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") {
+ failures.push(format!("{subtype} /{policy} image payload remains in full save"));
+ }
+ if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") {
+ failures.push(format!("{subtype} /{policy} Form payload remains in full save"));
+ }
+ let saved = Document::open(Arc::new(bytes)).unwrap();
+ if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) {
+ failures.push(format!("{subtype} /{policy} Form text remains extractable"));
+ }
+ if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() {
+ failures.push(format!("{subtype} /{policy} image remains on the page"));
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
+
+#[test]
+fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() {
+ use crate::sanitize::{Hidden, scan};
+
+ let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff");
+ let page = pdfcraft_model::pages(&doc)[0].obj;
+ let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do")));
+ doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap();
+
+ let original = write_full(&doc, &SaveOptions::default()).unwrap();
+ let counts = scan(&doc);
+ assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}");
+ assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document");
+}
+
+#[test]
+fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut doc = one_page(
+ b"/Bad Do",
+ "/XObject << /Bad 7 0 R >>",
+ vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")],
+ );
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM"));
+}
+
+#[test]
+fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH";
+ // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed
+ // conservatively without being entered. Verify must count that removal or sanitize
+ // can skip the HiddenLayers pass entirely.
+ let mut forms = Vec::new();
+ for depth in 0..=12 {
+ let content = if depth == 12 {
+ b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec()
+ } else {
+ format!("/F{} Do", depth + 1).into_bytes()
+ };
+ forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content));
+ }
+ let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" ");
+ let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms);
+ assert!(
+ write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
+ "fixture lacked nested payload before sanitization"
+ );
+
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}");
+ let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ assert!(
+ removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0),
+ "depth-limited Form was not reported removed: {removed:?}"
+ );
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save");
+}
+
+#[test]
+fn hidden_ocmd_resolves_an_indirect_policy_name() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(),
+ ],
+ );
+ doc.set(ObjRef::new(12, 0), Object::name("AnyOn"));
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
+}
+
+#[test]
+fn surviving_anyoff_and_not_content_keeps_its_off_group_state() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ for use_not in [false, true] {
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() },
+ ],
+ );
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let root = doc.root().unwrap();
+ let catalog = doc.get(root);
+ let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o));
+ let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o));
+ let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o));
+ assert!(
+ off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))),
+ "off group lost for /VE Not={use_not}"
+ );
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
+ }
+}
+
+#[test]
+fn hidden_ocmd_policy_matches_the_default_configuration() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let cases = [
+ (None, false, None, false),
+ (Some("AnyOn"), false, None, false),
+ (Some("AllOn"), false, None, true),
+ (Some("AnyOff"), false, None, false),
+ (Some("AllOff"), false, None, true),
+ (Some("UnknownPolicy"), false, None, true),
+ (None, true, None, true),
+ (None, false, Some("and"), true),
+ (None, false, Some("and_single_not"), true),
+ (None, false, Some("and_single_off"), true),
+ (None, false, Some("or_single_on"), false),
+ (None, false, Some("or_single_off"), true),
+ (Some("AllOn"), false, Some("not_off"), false),
+ (None, false, Some("malformed_not"), true),
+ (None, false, Some("malformed_without_ocgs"), true),
+ ];
+ for (policy, malformed_policy, visibility, should_remove) in cases {
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
+ ],
+ );
+ let mut membership = Dict::new();
+ membership.set(b"Type".to_vec(), Object::name("OCMD"));
+ if visibility != Some("malformed_without_ocgs") {
+ membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))]));
+ }
+ if let Some(policy) = policy {
+ membership.set(b"P".to_vec(), Object::name(policy));
+ } else if malformed_policy {
+ membership.set(b"P".to_vec(), Object::Int(17));
+ }
+ match visibility {
+ Some("and") => membership
+ .set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ Some("and_single_not") => membership.set(
+ b"VE".to_vec(),
+ Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]),
+ ),
+ Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])),
+ Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])),
+ Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])),
+ Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])),
+ Some("malformed_not") => membership
+ .set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ Some("malformed_without_ocgs") => {
+ membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
+ }
+ _ => {}
+ }
+ doc.set(ObjRef::new(9, 0), Object::Dict(membership));
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (
+ b"D".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
+ ])),
+ ),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"));
+ assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}");
+ }
+}
+
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA
From 3e3064532f04fd6ec769c6bd7b2c97464757970e Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 21 Oct 2026 15:27:33 -0400
Subject: [PATCH 07/18] Release: PdfCraft v0.11.0 (#3893)
* Release: PdfCraft v0.11.0
- Bump the workspace to 0.11.0.
- Fix main's red CI (cargo-deny): update rustls-webpki 0.103.15 -> 0.103.16
(RUSTSEC-2026-0382) and webpki-root-certs 1.0.9 -> 1.0.10 (RUSTSEC-2026-0383),
both reached through winprint's sigstore build dependency.
- `cargo xtask version set` now moves the workspace crates' pins in
[workspace.dependencies] with the version; before, `cargo update --workspace`
refused the bump and the pins had to be edited by hand.
Co-Authored-By: Claude Opus 5.5
* Tests: a healthy private retry may answer during the watchdog pass that scheduled it
shared_timeouts_retry_healthy_pages_privately_before_blacklisting failed on
CI (PR #3893): the try_recv that runs the watchdog spawns the private retries,
and page 1's retry has no gate, so on a loaded runner its healthy result
arrived before that same call read the channel. Accept that early answer
(page 1, no error) and use it as the healthy result; an error still fails.
Reproduced by sleeping 300 ms after the watchdog in try_recv: the old test
fails every time, the new one passes.
Co-Authored-By: Claude Opus 5.5
---------
Co-authored-by: Claude Opus 5.5
---
Cargo.lock | 92 ++++++++++++++++++-------------------
Cargo.toml | 68 +++++++++++++--------------
crates/render/src/raster.rs | 15 +++++-
xtask/src/version.rs | 25 +++++++++-
4 files changed, 117 insertions(+), 83 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 2399531ef4..e9505abbc0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1887,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -3938,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"craft-quit",
"eframe",
@@ -3963,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4035,18 +4035,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.10.1"
+version = "0.11.0"
[[package]]
name = "pdfcraft-content"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -4060,7 +4060,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4076,7 +4076,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"aes",
"md-5",
@@ -4088,7 +4088,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4103,7 +4103,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4143,14 +4143,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"flate2",
"proptest",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4182,18 +4182,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.10.1"
+version = "0.11.0"
[[package]]
name = "pdfcraft-js"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,14 +4206,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4223,7 +4223,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4236,11 +4236,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.10.1"
+version = "0.11.0"
[[package]]
name = "pdfcraft-organize"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4251,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,7 +4261,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4272,7 +4272,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4299,7 +4299,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4352,11 +4352,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.10.1"
+version = "0.11.0"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -4412,7 +4412,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.10.1"
+version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -4934,7 +4934,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5484,7 +5484,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5554,7 +5554,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5565,9 +5565,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -6431,7 +6431,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -7306,9 +7306,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -7514,7 +7514,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index b00b726dcb..18afce999f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.10.1"
+version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.10.1" }
-pdfcraft-filters = { path = "crates/filters", version = "0.10.1" }
-pdfcraft-cos = { path = "crates/cos", version = "0.10.1" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" }
-pdfcraft-organize = { path = "crates/organize", version = "0.10.1" }
-pdfcraft-measure = { path = "crates/measure", version = "0.10.1" }
-pdfcraft-annot = { path = "crates/annot", version = "0.10.1" }
-pdfcraft-audit = { path = "crates/audit", version = "0.10.1" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" }
-pdfcraft-model = { path = "crates/model", version = "0.10.1" }
-pdfcraft-content = { path = "crates/content", version = "0.10.1" }
-pdfcraft-redact = { path = "crates/redact", version = "0.10.1" }
-pdfcraft-print = { path = "crates/print", version = "0.10.1" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" }
-pdfcraft-sign = { path = "crates/sign", version = "0.10.1" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" }
-pdfcraft-js = { path = "crates/js", version = "0.10.1" }
-pdfcraft-compare = { path = "crates/compare", version = "0.10.1" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" }
-pdfcraft-export = { path = "crates/export", version = "0.10.1" }
-pdfcraft-edit = { path = "crates/edit", version = "0.10.1" }
-pdfcraft-create = { path = "crates/create", version = "0.10.1" }
-pdfcraft-forms = { path = "crates/forms", version = "0.10.1" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" }
-pdfcraft-render = { path = "crates/render", version = "0.10.1" }
-pdfcraft-engine = { path = "crates/engine", version = "0.10.1" }
-pdfcraft-platform = { path = "crates/platform", version = "0.10.1" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" }
-pdfcraft-automation = { path = "crates/automation", version = "0.10.1", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.11.0" }
+pdfcraft-filters = { path = "crates/filters", version = "0.11.0" }
+pdfcraft-cos = { path = "crates/cos", version = "0.11.0" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" }
+pdfcraft-organize = { path = "crates/organize", version = "0.11.0" }
+pdfcraft-measure = { path = "crates/measure", version = "0.11.0" }
+pdfcraft-annot = { path = "crates/annot", version = "0.11.0" }
+pdfcraft-audit = { path = "crates/audit", version = "0.11.0" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" }
+pdfcraft-model = { path = "crates/model", version = "0.11.0" }
+pdfcraft-content = { path = "crates/content", version = "0.11.0" }
+pdfcraft-redact = { path = "crates/redact", version = "0.11.0" }
+pdfcraft-print = { path = "crates/print", version = "0.11.0" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" }
+pdfcraft-sign = { path = "crates/sign", version = "0.11.0" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" }
+pdfcraft-js = { path = "crates/js", version = "0.11.0" }
+pdfcraft-compare = { path = "crates/compare", version = "0.11.0" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" }
+pdfcraft-export = { path = "crates/export", version = "0.11.0" }
+pdfcraft-edit = { path = "crates/edit", version = "0.11.0" }
+pdfcraft-create = { path = "crates/create", version = "0.11.0" }
+pdfcraft-forms = { path = "crates/forms", version = "0.11.0" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" }
+pdfcraft-render = { path = "crates/render", version = "0.11.0" }
+pdfcraft-engine = { path = "crates/engine", version = "0.11.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.11.0" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" }
+pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false }
# pdf
hayro = "0.7.1"
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 45641178de..c10ab74d15 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -1553,13 +1553,24 @@ mod tests {
]);
shared_bad.entered.wait();
shared_healthy.entered.wait();
- assert!(pool.try_recv().is_none(), "shared timeouts retry instead of answering yet");
+ // This watchdog pass retries both pages privately. Page 1's retry has no gate, so on a
+ // loaded machine it can finish before this same call reads the results: that early
+ // answer is the healthy page, never a timeout error.
+ let early = pool.try_recv();
+ assert!(
+ early.as_ref().is_none_or(|page| page.request.page == 1 && page.error.is_none()),
+ "shared timeouts retry instead of answering with an error: {:?}",
+ early.as_ref().map(|page| (page.request.page, &page.error))
+ );
shared_bad.release.wait();
shared_healthy.release.wait();
shared_bad.finished.wait();
shared_healthy.finished.wait();
private_bad.entered.wait();
- let healthy = pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry");
+ let healthy = match early {
+ Some(page) => page,
+ None => pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"),
+ };
assert!(healthy.error.is_none(), "{:?}", healthy.error);
assert_eq!((healthy.request.page, healthy.width, healthy.height, healthy.request.tag), (1, 100, 50, 41));
pool.watchdog();
diff --git a/xtask/src/version.rs b/xtask/src/version.rs
index 0673653178..ed091509bc 100644
--- a/xtask/src/version.rs
+++ b/xtask/src/version.rs
@@ -15,12 +15,21 @@ pub fn read(manifest: &str) -> Result {
Ok(v)
}
-/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
+/// `manifest` with the workspace version replaced, together with the matching `version` of every
+/// path dependency in `[workspace.dependencies]` (the workspace's own crates, which must agree
+/// with it or `cargo update` refuses them); everything else is byte-for-byte unchanged.
pub fn replace(manifest: &str, new: &str) -> Result {
validate(new)?;
+ let old = read(manifest)?;
let (idx, _) = find_line(manifest)?;
+ let (old_pin, new_pin) = (format!("version = \"{old}\""), format!("version = \"{new}\""));
let mut out = String::with_capacity(manifest.len() + 8);
+ let mut in_deps = false;
for (i, line) in manifest.split_inclusive('\n').enumerate() {
+ let t = line.trim();
+ if t.starts_with('[') {
+ in_deps = t == "[workspace.dependencies]";
+ }
if i == idx {
let eol = if line.ends_with("\r\n") {
"\r\n"
@@ -30,6 +39,8 @@ pub fn replace(manifest: &str, new: &str) -> Result {
""
};
out.push_str(&format!("version = \"{new}\"{eol}"));
+ } else if in_deps && line.contains("path = \"") && line.contains(&old_pin) {
+ out.push_str(&line.replace(&old_pin, &new_pin));
} else {
out.push_str(line);
}
@@ -131,6 +142,18 @@ mod tests {
assert!(out.contains("foo = { version = \"1\" }"));
}
+ #[test]
+ fn moves_the_workspace_crates_pins_with_it() {
+ let manifest = "[workspace.package]\nversion = \"0.1.0\"\n\n[workspace.dependencies]\n\
+ own = { path = \"crates/own\", version = \"0.1.0\" }\n\
+ dep = { version = \"0.1.0\" }\n\
+ other = { path = \"crates/other\", version = \"0.2\" }\n";
+ let out = replace(manifest, "0.2.0").unwrap();
+ assert!(out.contains("own = { path = \"crates/own\", version = \"0.2.0\" }"), "{out}");
+ assert!(out.contains("dep = { version = \"0.1.0\" }"), "a registry dependency keeps its version");
+ assert!(out.contains("other = { path = \"crates/other\", version = \"0.2\" }"));
+ }
+
#[test]
fn keeps_crlf_line_endings() {
let crlf = MANIFEST.replace('\n', "\r\n");
From 30b8c60b557a8d79e4114a984e2ca47b3a89b628 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 15:47:03 -0400
Subject: [PATCH 08/18] ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
---
.github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 .github/workflows/sonarcloud.yml
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 0000000000..ecee45a6d8
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,20 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
From b1bf1cf01ff8d513a7a9bcb87d99933f2fb643f8 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 16:00:45 -0400
Subject: [PATCH 09/18] ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
---
sonar-project.properties | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 sonar-project.properties
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000000..30e80a0c5f
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,2 @@
+sonar.projectKey=storytold_pdfcraft
+sonar.organization=storytold
From 322a1b2ac01a56d926406f784a48a0d5cdeda79e Mon Sep 17 00:00:00 2001
From: abc2cb
Date: Thu, 22 Oct 2026 00:33:59 +0300
Subject: [PATCH 10/18] M7: lay out the Edit panel as Acrobat's (#2245)
* M7: the Edit tab turns on Edit text & images, as in Acrobat
Entering Edit boxes every paragraph and image on the page at once, so text
is editable with one click instead of first finding Edit text & images in
the tool list. The floating toolbar gets an Edit text & images button while
Edit is open or the tool is in use. Leaving Edit (or turning the tool off
there) applies a paragraph still open in the editor, as clicking away from
it does; left open, its page kept showing the paragraph's text removed.
Co-Authored-By: Claude Opus 5.5
* M7: parity notes for the Edit tab turning on Edit text & images
Co-Authored-By: Claude Opus 5.5
* M7: the panel-reveal test looks for the Edit panel's own row
With Edit text & images on, its name is on the floating toolbar as well as in the panel, so
the label alone matched twice and no longer showed that the Tools tab was revealed.
Co-Authored-By: Claude Opus 5.5
* M7: lay out the Edit panel as Acrobat's
Under the Edit tab the panel is titled Edit, with Preferences and Close;
Modify page is a row of icon buttons (rotate, insert, delete, extract) and
Organize pages; Add content lists Text, Image, Header and footer, Watermark
and Link, with the rest under More; Other options links Combine files,
Redact a PDF and Prepare a form. The milestone banner and Ready chips are
gone; a planned item is muted and says when it ships. Every row still
comes from the catalogue. Edit text & images is on the floating toolbar.
Co-Authored-By: Claude Opus 5.5
* M7: Organize pages in the Edit panel opens the Organize workspace
It ran page.organize, which showed the page grid behind the Edit panel;
Acrobat replaces the panel with Organize pages' own. It now selects the
Organize tool, so its panel and the grid open together.
Co-Authored-By: Claude Opus 5.5
* M7: translate the Edit panel's Less and Other options
Every catalogue gets the two strings the Acrobat-style Edit panel adds.
Co-Authored-By: Claude Opus 5.5
* chore(i18n): refresh the catalogue hashes in ATTRIBUTION.toml
Co-Authored-By: Claude Opus 5.5
* M7: no idle selection hint above Format text in the Edit panel
"Shift-click to select several objects. Drag empty space to select an
area." sat at the top of the Edit panel whenever Edit text & images was
on, pushing Format text down; Acrobat's panel has no such line. The
selection count and how to move it still show while objects are
selected. Tests that read the hint as "nothing selected" check that no
selection count shows instead.
Co-Authored-By: Claude Opus 5.5
* M7: let auto-deref find the Edit panel's buttons (clippy)
Co-Authored-By: Claude Opus 5.5
* M7: the Edit panel keeps main's Add / Update / Remove menu
Main gave Header and footer, Watermark and Background a menu of Add,
Update and Remove in the tool panel (#1857). This branch draws the Edit
group in its own panel, so those rows ran only Add and Update / Remove
were unreachable there. Both panels now share one helper for the caret
and the menu.
Co-Authored-By: Claude Opus 5.5
* Edit panel: Modify page buttons follow command enablement; Organize pages with no document asks for a file
CodeRabbit on #2245: the four Modify page buttons were enabled from the catalogue
(always Ready) while execute() refuses them on a document that forbids page assembly;
they now use command_enabled. Organize pages with no document open selected a panel
with no grid to show; it now runs page.organize, which asks for a file first.
Co-Authored-By: Claude Sonnet 5.5
---------
Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
---
ATTRIBUTION.toml | 44 ++---
crates/ui-egui/src/i18n/ar.tsv | 2 +
crates/ui-egui/src/i18n/bg.tsv | 2 +
crates/ui-egui/src/i18n/cs.tsv | 2 +
crates/ui-egui/src/i18n/de.tsv | 2 +
crates/ui-egui/src/i18n/el.tsv | 2 +
crates/ui-egui/src/i18n/es.tsv | 2 +
crates/ui-egui/src/i18n/fr.tsv | 2 +
crates/ui-egui/src/i18n/hu.tsv | 2 +
crates/ui-egui/src/i18n/it.tsv | 2 +
crates/ui-egui/src/i18n/ja.tsv | 2 +
crates/ui-egui/src/i18n/kk.tsv | 2 +
crates/ui-egui/src/i18n/lt.tsv | 2 +
crates/ui-egui/src/i18n/pl.tsv | 2 +
crates/ui-egui/src/i18n/pt-br.tsv | 2 +
crates/ui-egui/src/i18n/ru.tsv | 2 +
crates/ui-egui/src/i18n/sl.tsv | 2 +
crates/ui-egui/src/i18n/te.tsv | 2 +
crates/ui-egui/src/i18n/tr.tsv | 2 +
crates/ui-egui/src/i18n/uk.tsv | 2 +
crates/ui-egui/src/i18n/vi.tsv | 2 +
crates/ui-egui/src/i18n/zh-hans.tsv | 2 +
crates/ui-egui/src/i18n/zh-hant.tsv | 2 +
crates/ui-egui/src/panels.rs | 210 +++++++++++++++++++----
crates/ui-egui/tests/editing.rs | 10 +-
crates/ui-egui/tests/object_selection.rs | 15 +-
crates/ui-egui/tests/ui.rs | 35 ++++
27 files changed, 295 insertions(+), 63 deletions(-)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index db3dd6124f..fbabdf6d44 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 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
+sha256 = "e2ea97b4c68e13c22ef7f6f39f9a29d21af0e562005e2fdc9e7dfdf29effe0bb"
# 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 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
+sha256 = "ac0a78f36b91c005b9543e6028b78bb44949fea1bf7991bf0791d407696cd832"
[[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 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
+sha256 = "c52b9a2aa403937e70859fe60b51719c97475c24927322d7534f5da0bdb5f420"
[[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 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
+sha256 = "0dcad418fa6399955adf2a8398aec307c2c73b13a37ca78ffe142e00491ef586"
[[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 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
+sha256 = "130f0256d4ba09bc9cb6a87fc973bad96d6b155703b713341f0afead9ad6b4b3"
[[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 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
+sha256 = "d1093647d2af3d51f77a7b53f05ef1f45442128d40e696022624fbbece5bd0f0"
[[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 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
+sha256 = "135d00bc60ef62f45ebee7c7fd83bc7433fd4054a04fb3901b2d2e8bdd2871c7"
[[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 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
+sha256 = "0cb38f42fe45831495699f81837fb0b2d5bb16b1b69153359c4376e9bc1e6e04"
[[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 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
+sha256 = "ce97821a0fd6bf758e88e654f70985ed1e94c81e36f4e31ee3fc2f05ca39d99f"
[[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 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
+sha256 = "45567329bdb7884b0b54e5c5161e6e73452d85200945f1ba3661eab1fa35da06"
[[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 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
+sha256 = "421ef329305624ebfc661a8d9cd82131e79ec279b8be47d4baec29a226e494d9"
[[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 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
+sha256 = "97ed35e8959777a5f15b087136e53385430241e8522c7dc72a383c72dc0a27fd"
[[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 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
+sha256 = "bc012e0dd9ad0d212a32b71bfc75a42ffcd058484aab28d173078384c9da480f"
[[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 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
+sha256 = "bd611d470f40a01b4442119f7189dabbe58b4904dbce398115399f02b2ecddaa"
[[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 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
+sha256 = "1132e904bc1a2bcc8239987aa74b2fa75ca17d8eb20b5458eec7e2c075c0445a"
[[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 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
+sha256 = "11dd8250c667ca49c40b827632b2d83cad93d07a9d5a5f27b5b9d657293efb6b"
[[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 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
+sha256 = "6aa0788d784532fb67a0b589cecc27f727e6178221ee892c3de7fe8f75790607"
[[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 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
+sha256 = "ed84a08b83035bc6f4d328b916692d40140c2748d383741d04fba6e013a76bb3"
[[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 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
+sha256 = "1a5b3e9ad3f524e04bdb60e11f4a1424ca749a5ed4840e22abeaa97d24d235e8"
[[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 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
+sha256 = "136f1e68fc4caa5e6e8fe395551bffdc9ace93bb00e2da5ba59318846494420d"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
+sha256 = "052ecb803601ebc547934136c936fb5527d6dc97e49cf8cfbde0aa2b1a6cb8c7"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
+sha256 = "299c274d071478c49430d8dbc2a1979085fa4f8735324697e8ac0db3d3adce7c"
[[asset]]
diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv
index 8697887e10..4bb58ba8e1 100644
--- a/crates/ui-egui/src/i18n/ar.tsv
+++ b/crates/ui-egui/src/i18n/ar.tsv
@@ -2711,3 +2711,5 @@ certificate Version الإصدار
Make properties default جعل الخصائص افتراضية
Pages selected الصفحات المحددة
Page properties خصائص الصفحة
+ Less أقل
+ Other options خيارات أخرى
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 813a2c7ae8..584fafda3f 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2397,3 +2397,5 @@ animation effects Off Изключени
Make properties default Задаване като свойства по подразбиране
Pages selected Избрани страници
Page properties Свойства на страницата
+ Less По-малко
+ Other options Други опции
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 9936325523..d347ca95d7 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -2508,3 +2508,5 @@ animation effects Off Vypnuto
Make properties default Nastavit vlastnosti jako výchozí
Pages selected Vybrané stránky
Page properties Vlastnosti stránky
+ Less Méně
+ Other options Další možnosti
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 4d9207eb9d..cb1ffc2fd9 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2435,3 +2435,5 @@ animation effects Off Aus
Make properties default Eigenschaften als Standard festlegen
Pages selected Ausgewählte Seiten
Page properties Seiteneigenschaften
+ Less Weniger
+ Other options Weitere Optionen
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 06fe2b540b..2bde0b50b9 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -2440,3 +2440,5 @@ animation effects Off Ανενεργά
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
Pages selected Επιλεγμένες σελίδες
Page properties Ιδιότητες σελίδας
+ Less Λιγότερα
+ Other options Άλλες επιλογές
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 79c668992c..fbc9f54c98 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2570,6 +2570,8 @@ animation effects Off Desactivados
Make properties default Usar como propiedades predeterminadas
Pages selected Páginas seleccionadas
Page properties Propiedades de página
+ Less Menos
+ Other options Otras opciones
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 9bdcf0bc8a..bde247a8d8 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2434,3 +2434,5 @@ animation effects Off Désactivés
Make properties default Définir comme propriétés par défaut
Pages selected Pages sélectionnées
Page properties Propriétés de la page
+ Less Moins
+ Other options Autres options
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index d537677c7d..16efc30a0d 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2433,3 +2433,5 @@ animation effects Off Ki
Make properties default Tulajdonságok beállítása alapértelmezettként
Pages selected Kijelölt oldalak
Page properties Oldaltulajdonságok
+ Less Kevesebb
+ Other options Egyéb beállítások
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 35cba7a410..c46f9cf81b 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2646,3 +2646,5 @@ certificate Key usage Utilizzo chiave
certificate Self-signed Autofirmato
certificate SHA-1 digest Impronta SHA-1
certificate SHA-256 digest Impronta SHA-256
+ Less Meno
+ Other options Altre opzioni
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index e845a736da..54bc546338 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2434,3 +2434,5 @@ animation effects Off オフ
Make properties default プロパティをデフォルトに設定
Pages selected 選択したページ
Page properties ページのプロパティ
+ Less 折りたたむ
+ Other options その他のオプション
diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv
index 9acb4ec1be..5ca11b941d 100644
--- a/crates/ui-egui/src/i18n/kk.tsv
+++ b/crates/ui-egui/src/i18n/kk.tsv
@@ -2432,3 +2432,5 @@ animation effects Off Өшірулі
Make properties default Қасиеттерді әдепкі ету
Pages selected Таңдалған беттер
Page properties Бет қасиеттері
+ Less Азырақ
+ Other options Басқа параметрлер
diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv
index d1dfc33044..2674dfc86b 100644
--- a/crates/ui-egui/src/i18n/lt.tsv
+++ b/crates/ui-egui/src/i18n/lt.tsv
@@ -2685,3 +2685,5 @@ key Space Tarpas
The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai.
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti.
theme Appearance Išvaizda
+ Less Mažiau
+ Other options Kitos parinktys
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index c432a6a92e..d6d5e5e93b 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -2410,3 +2410,5 @@ animation effects Off Wyłączone
Make properties default Ustaw właściwości jako domyślne
Pages selected Zaznaczone strony
Page properties Właściwości strony
+ Less Mniej
+ Other options Inne opcje
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 89ff80f9dd..ee041f4af1 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2448,3 +2448,5 @@ animation effects Off Desativados
Make properties default Tornar propriedades padrão
Pages selected Páginas selecionadas
Page properties Propriedades da página
+ Less Menos
+ Other options Outras opções
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 73f21aa0d8..72fcb1ddcf 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2347,3 +2347,5 @@ animation effects Off Выкл
Make properties default Сделать свойства стандартными
Pages selected Выделенные страницы
Page properties Свойства страницы
+ Less Меньше
+ Other options Другие параметры
diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv
index dd169bc961..009131307f 100644
--- a/crates/ui-egui/src/i18n/sl.tsv
+++ b/crates/ui-egui/src/i18n/sl.tsv
@@ -2439,3 +2439,5 @@ animation effects Off Izklopljeni
Make properties default Nastavi lastnosti kot privzete
Pages selected Izbrane strani
Page properties Lastnosti strani
+ Less Manj
+ Other options Druge možnosti
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 47aee36f8b..80008d1912 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -2343,3 +2343,5 @@ animation effects Off ఆఫ్
Make properties default లక్షణాలను డిఫాల్ట్గా చేయి
Pages selected ఎంచుకున్న పేజీలు
Page properties పేజీ లక్షణాలు
+ Less తక్కువ
+ Other options ఇతర ఎంపికలు
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 95ca0b608c..b185f0262e 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -2430,3 +2430,5 @@ animation effects Off Kapalı
Make properties default Özellikleri varsayılan yap
Pages selected Seçilen sayfalar
Page properties Sayfa özellikleri
+ Less Daha az
+ Other options Diğer seçenekler
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 7993a06185..c25eba25b7 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2456,3 +2456,5 @@ animation effects Off Вимкнено
Make properties default Зробити властивості типовими
Pages selected Вибрані сторінки
Page properties Властивості сторінки
+ Less Менше
+ Other options Інші параметри
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index bae599bf6a..dbf9eb2245 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2683,6 +2683,8 @@ key Space Space
Words Từ
“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này.
@id file.word_count Đếm từ…
+ Less Ít hơn
+ Other options Tùy chọn khác
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 1ddecc655f..a71b3f30e9 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2398,3 +2398,5 @@ animation effects Off 关
Make properties default 将属性设为默认
Pages selected 选定的页面
Page properties 页面属性
+ Less 收起
+ Other options 其他选项
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index bb5ec3649b..630bfbf34b 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2384,3 +2384,5 @@ animation effects Off 關
Make properties default 將內容設為預設
Pages selected 選取的頁面
Page properties 頁面屬性
+ Less 收起
+ Other options 其他選項
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 0f2c601497..8d6660b643 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -2,7 +2,7 @@
//! right-hand panels (Comments, Bookmarks, Pages, Fields, Layers, Attachments).
use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, pos2, vec2};
-use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup};
+use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup, ToolItem};
use pdfcraft_render::{DocInfo, FieldKind, OutlineItem};
use crate::theme::{self, Tokens};
@@ -144,6 +144,10 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
}
fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) {
+ if g.id == "edit" {
+ edit_panel(app, ui, t, g);
+ return;
+ }
let (back, close) = panel_header(ui, t, g.label, true);
if back {
app.left = LeftPanel::AllTools;
@@ -185,20 +189,6 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
});
ui.add_space(6.0);
}
- // Edit a PDF shows Format text at the top while text is selected or being added.
- if g.id == "edit" {
- if app.quick_tool == crate::QuickTool::EditText {
- let count = app.active_ids().map_or(0, |(i, _)| app.views[i].objects.count());
- if count > 0 {
- ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected"));
- ui.label(tl!("Drag the selection to move it. Esc clears the selection."));
- } else {
- ui.label(tl!("Shift-click to select several objects. Drag empty space to select an area."));
- }
- ui.add_space(6.0);
- }
- format_section(app, ui, t);
- }
let mut run = None;
// Redact a PDF has Acrobat's footer: Clear all / Redact all.
let footer = g.id == "redact";
@@ -238,31 +228,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
ui.painter().rect_filled(r, CornerRadius::same(4), fill);
ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg);
let resp = theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command));
+ // An entry with further commands opens a menu; its caret goes before the chip.
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();
- }
- }
- });
+ } else if let Some(cmd) = actions_menu(app, ui, &resp, item, r.left_center() - vec2(10.0, 0.0), fg) {
+ run = Some(cmd);
}
}
}
@@ -301,6 +273,172 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
}
}
+/// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does.
+const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"];
+/// Add content items shown before More.
+const EDIT_CONTENT_SHOWN: usize = 5;
+/// Other options: the tools Acrobat's Edit panel links to.
+const EDIT_OTHER_TOOLS: [&str; 3] = ["combine", "redact", "form"];
+
+/// Edit a PDF, laid out as Acrobat's Edit panel: the title with Preferences and Close; Format text
+/// while text is being edited or added; Modify page as a row of icon buttons and Organize pages;
+/// Add content with the rarer items under More; and Other options, the neighbouring tools.
+/// Edit text & images itself is on the floating toolbar, and on from the moment Edit opens.
+/// Every item comes from the catalogue, so its label, icon and availability have one source.
+fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) {
+ let items = || g.sections.iter().flat_map(|s| s.items.iter());
+ let item = |command: &str| items().find(|i| i.command == command);
+ let mut run = None;
+ let mut tool = None;
+ // Back to All tools only when Edit was opened from there; the Edit tab is its own way back.
+ let from_all_tools = app.mode != crate::Mode::Edit;
+ ui.horizontal(|ui| {
+ if from_all_tools && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() {
+ app.left = LeftPanel::AllTools;
+ app.mode = crate::Mode::AllTools;
+ }
+ // "Edit" under the Edit tab, as Acrobat titles it; the tool's own name from All tools.
+ let title = if from_all_tools { tl!(g.label) } else { tl!("Edit") };
+ let title_width = (ui.available_width() - 2.0 * (26.0 + ui.spacing().item_spacing.x)).max(1.0);
+ ui.add_sized([title_width, 26.0], egui::Label::new(egui::RichText::new(title).font(theme::semibold(15.5)).color(t.text)).truncate())
+ .on_hover_text(title);
+ if icons::button(ui, "settings", 26.0, false, tl!("Preferences")).clicked() {
+ run = Some("app.preferences");
+ }
+ if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() {
+ app.left_open = false;
+ }
+ });
+ ui.add_space(2.0);
+ ui.separator();
+ ui.add_space(4.0);
+ // Several objects selected: how many, and how to move them. Acrobat's panel has no idle hint.
+ let count = app.active_ids().filter(|_| app.quick_tool == crate::QuickTool::EditText).map_or(0, |(i, _)| app.views[i].objects.count());
+ if count > 0 {
+ ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected"));
+ ui.label(tl!("Drag the selection to move it. Esc clears the selection."));
+ ui.add_space(6.0);
+ }
+ // Format text at the top while text is selected or being added.
+ format_section(app, ui, t);
+ egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
+ widgets::section_title(ui, tl!("Modify page"));
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 6.0;
+ for i in EDIT_PAGE_BUTTONS.iter().filter_map(|c| item(c)) {
+ // Enabled as the command itself is: a document that forbids page assembly refuses these.
+ let enabled = pdfcraft_engine::commands::command(i.command).is_some_and(|spec| app.command_enabled(spec));
+ let resp = ui.add_enabled_ui(enabled, |ui| icons::button(ui, i.icon, 34.0, false, tl!(i.label))).inner;
+ if resp.clicked() {
+ run = Some(i.command);
+ }
+ }
+ });
+ ui.add_space(2.0);
+ // Organize pages is a workspace of its own: its panel replaces this one, with the grid.
+ if let Some(i) = item("page.organize")
+ && edit_row(ui, t, i.icon, tl!(i.label), i.availability).clicked()
+ {
+ // With no document there is no grid to open: the command asks for a file first.
+ if app.active.is_some() {
+ tool = Some("organize");
+ } else {
+ run = Some("page.organize");
+ }
+ }
+
+ widgets::section_title(ui, tl!("Add content"));
+ let content: Vec<_> =
+ g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect();
+ let more_id = egui::Id::new("edit_panel_more");
+ let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false);
+ let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) };
+ for i in content.iter().take(shown) {
+ let resp = edit_row(ui, t, i.icon, tl!(i.label), i.availability);
+ // Header and footer, Watermark and Background open their Add / Update / Remove menu.
+ if i.actions.is_empty() {
+ if resp.clicked() {
+ run = Some(i.command);
+ }
+ } else if let Some(cmd) = actions_menu(app, ui, &resp, i, resp.rect.right_center() - vec2(14.0, 0.0), t.text) {
+ run = Some(cmd);
+ }
+ }
+ if content.len() > EDIT_CONTENT_SHOWN {
+ ui.add_space(4.0);
+ let label = if more { tl!("Less") } else { tl!("More") };
+ let resp = ui
+ .add(
+ egui::Label::new(egui::RichText::new(label).color(t.accent_text).font(theme::medium(13.0)))
+ .selectable(false)
+ .sense(Sense::click()),
+ )
+ .on_hover_cursor(egui::CursorIcon::PointingHand);
+ if resp.clicked() {
+ ui.data_mut(|d| d.insert_temp(more_id, !more));
+ }
+ }
+
+ widgets::section_title(ui, tl!("Other options"));
+ for other in EDIT_OTHER_TOOLS.iter().filter_map(|id| catalog::group(id)) {
+ if edit_row(ui, t, other.icon, tl!(other.label), other.availability).clicked() {
+ tool = Some(other.id);
+ }
+ }
+ });
+ if let Some(id) = tool {
+ app.select_tool(id);
+ }
+ if let Some(cmd) = run {
+ app.run_command(cmd);
+ }
+}
+
+/// A tool entry with further commands (Add / Update / Remove): a caret at `caret` says it opens a
+/// menu, and each command is enabled by its own needs. Returns the command chosen.
+fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: &ToolItem, caret: egui::Pos2, color: Color32) -> Option<&'static str> {
+ ui.painter().add(egui::Shape::convex_polygon(
+ vec![caret + vec2(-3.5, -2.0), caret + vec2(3.5, -2.0), caret + vec2(0.0, 2.5)],
+ color,
+ egui::Stroke::NONE,
+ ));
+ let mut run = 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();
+ }
+ }
+ });
+ run
+}
+
+/// One row of the Edit panel: a monochrome icon and the label, as Acrobat draws them. A planned
+/// item is muted and says when it ships when hovered.
+fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response {
+ let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
+ let press = theme::Press::track(ui, &resp);
+ press.wash(ui, rect, 6, false);
+ let body = rect.translate(press.offset());
+ let ready = availability == Availability::Ready;
+ let fg = if ready { t.text } else { t.text_muted };
+ icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)), icon, 17.0, if ready { t.text } else { t.text_faint });
+ row_label(ui, label, theme::regular(13.5), fg, body.left_center() + vec2(34.0, 0.0), rect.width() - 42.0);
+ let resp = theme::hand(resp);
+ match availability {
+ Availability::Ready => resp,
+ Availability::Planned(m) => resp.on_hover_text(format!("{} {m}", tl!("Planned for milestone"))),
+ Availability::Provider => resp.on_hover_text(tl!("Optional: needs an AI provider you configure")),
+ }
+}
+
/// Add a stamp: Dynamic, Sign Here and Standard Business stamps; click one, then click on the
/// page to place it.
fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index d41041e52e..1890c1c320 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -3256,11 +3256,17 @@ fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset:
fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) {
let (from, to) = paragraph_drag_points(h, text, offset);
- h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
+ assert!(
+ h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
+ "no objects selected"
+ );
assert!(h.state().views[0].line_editor.is_none());
drag(h, from, to);
assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor");
- h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
+ assert!(
+ h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
+ "no objects selected"
+ );
}
fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) {
diff --git a/crates/ui-egui/tests/object_selection.rs b/crates/ui-egui/tests/object_selection.rs
index 343483378c..ceb700a1c0 100644
--- a/crates/ui-egui/tests/object_selection.rs
+++ b/crates/ui-egui/tests/object_selection.rs
@@ -146,7 +146,10 @@ fn marquee_and_mixed_group_drag_move_once_and_undo_together() {
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), before);
- h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
+ assert!(
+ h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
+ "no objects selected"
+ );
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), after);
@@ -171,7 +174,10 @@ fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() {
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
h.key_press(Key::Escape);
h.run_steps(3);
- h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
+ assert!(
+ h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
+ "no objects selected"
+ );
}
#[test]
@@ -221,7 +227,10 @@ fn a_document_change_invalidates_old_selection_before_the_next_drag() {
.apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } })
.unwrap();
h.run_steps(4);
- h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
+ assert!(
+ h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
+ "no objects selected"
+ );
assert_eq!(objects(&h).len(), 5);
}
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
index d30ab1b8dd..118dee8f74 100644
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -1425,3 +1425,38 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
h.run_steps(3);
assert_eq!(h.state().quick_tool, QuickTool::Hand);
}
+
+/// Organize pages in the Edit panel opens the Organize workspace: its own panel and the page grid,
+/// not the grid behind the Edit panel.
+#[test]
+fn organize_pages_from_the_edit_panel_opens_its_panel() {
+ use pdfcraft_ui_egui::LeftPanel;
+ let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
+ h.get_by_label("Edit").click();
+ h.run_steps(3);
+ assert_eq!(h.state().left, LeftPanel::Tool("edit"));
+ assert!(
+ h.query_by_label("Shift-click to select several objects. Drag empty space to select an area.").is_none(),
+ "no idle hint above Format text, as in Acrobat"
+ );
+ h.get_by_label("Organize pages").click();
+ h.run_steps(3);
+ assert_eq!(h.state().left, LeftPanel::Tool("organize"));
+ assert!(h.state().views[0].organize, "the page grid is showing");
+}
+
+/// Header and footer, Watermark and Background keep their Add / Update / Remove menu in the Edit
+/// panel, as under All tools: Update and Remove wait for the document to have that kind of mark.
+#[test]
+fn the_edit_panel_keeps_the_add_update_remove_menu() {
+ let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
+ h.get_by_label("Edit").click();
+ h.run_steps(3);
+ h.get_by_label("Watermark").click();
+ h.run_steps(3);
+ assert_eq!(enabled(&h, "Add watermark…"), Some(true));
+ assert_eq!(enabled(&h, "Remove watermark"), Some(false), "nothing to remove yet");
+ h.get_by_label("Add watermark…").click();
+ h.run_steps(3);
+ assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)));
+}
From 8f3456f29c9fbcb94a46e91fd9cdf58588a323a0 Mon Sep 17 00:00:00 2001
From: Piohrun
Date: Wed, 21 Oct 2026 23:34:11 +0200
Subject: [PATCH 11/18] =?UTF-8?q?fix(engine):=20PNG=20export=20writes=20op?=
=?UTF-8?q?aque=20pages=20as=20RGB=20at=20deflate=20level=203=20(3?=
=?UTF-8?q?=E2=80=935=C3=97=20faster,=20same=20size)=20(#2425)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(engine): PNG export writes opaque pages as RGB at deflate level 3
`encode_png` wrote every raster as RGBA at the png crate's default level
(zlib level 6). A rendered page is always opaque, so the alpha channel only
made the file bigger and slower to write. An opaque raster is now written as
RGB, at deflate level 3 with adaptive filtering; a raster with translucent
pixels is still written as RGBA with straight alpha.
The files come out the same size, 3-5x faster (release build, Linux):
300-dpi scan drawn at 220 dpi 805 -> 167 ms 4.46 -> 4.47 MB
text page at 220 dpi 70 -> 40 ms 0.85 -> 0.89 MB
annual-report page at 150 dpi 179 -> 64 ms 1.41 -> 1.38 MB
This is the encoder behind Export > Image (PNG), `pdfcraft-cli render` and
Print as Image; the automation tools (`page_render` and the other page
previews, MCP included) had a copy of it and now call it instead, so png is
only a dev-dependency of pdfcraft-automation. Two automation tests that read
`page_render` output as RGBA now accept the RGB a page renders as.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* fix(engine): PNG export rejects a raster of the wrong length
encode_png now checks that the buffer is exactly width × height × 4 bytes
(checked arithmetic) before choosing RGB or RGBA. The opaque path's
as_chunks::<4>() dropped a partial trailing pixel, so encode_png(1, 1,
&[10, 20, 30, 255, 0]) wrote a valid 1×1 PNG where the old RGBA encoder
returned an error.
Regression test export::png_tests::rasters_of_the_wrong_length_are_rejected
covers opaque and translucent buffers with a partial pixel, a pixel short,
a pixel too many, an empty buffer and a size whose byte count overflows; it
fails on the previous head at the reviewer's case.
Review: https://github.com/storytold/pdfcraft/pull/2425#discussion_r4281603405
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
---------
Co-authored-by: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/automation/Cargo.toml | 2 +-
crates/automation/src/lib.rs | 19 +-----
crates/automation/tests/automation.rs | 25 ++++---
crates/engine/src/export.rs | 96 ++++++++++++++++++++++++---
docs/roadmap.md | 2 +
6 files changed, 106 insertions(+), 39 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index b1283e7751..b910ce0b62 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -218,6 +218,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
+- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
- **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 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index 60bd9eef4c..26f36728cb 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
-png = "0.18"
base64 = { version = "0.22", optional = true }
@@ -28,6 +27,7 @@ mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-fonts = { workspace = true }
+png = "0.18"
flate2.workspace = true
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 0e2e0dd698..af0583717f 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -2506,24 +2506,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
}
+/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same).
fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> {
- let mut rgba = premultiplied.to_vec();
- for px in rgba.as_chunks_mut::<4>().0 {
- let a = u32::from(px[3]);
- if a != 0 && a != 255 {
- for c in &mut px[..3] {
- *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
- }
- }
- }
- let mut out = Vec::new();
- let mut enc = png::Encoder::new(&mut out, width, height);
- enc.set_color(png::ColorType::Rgba);
- enc.set_depth(png::BitDepth::Eight);
- let mut w = enc.write_header().map_err(failed)?;
- w.write_image_data(&rgba).map_err(failed)?;
- w.finish().map_err(failed)?;
- Ok(out)
+ pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
}
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index cbf40f1962..f760398a1e 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -3880,15 +3880,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() {
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
- assert_eq!(info.color_type, png::ColorType::Rgba);
- for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([
- [240, 20, 20, 255],
- [20, 180, 20, 255],
- [20, 20, 240, 255],
- [230, 180, 20, 255],
- ]) {
- let offset = ((y * info.width + x) * 4) as usize;
- assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}");
+ // A rendered page is opaque, so it comes as RGB.
+ assert_eq!(info.color_type, png::ColorType::Rgb);
+ for ((x, y), colour) in
+ [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]])
+ {
+ let offset = ((y * info.width + x) * 3) as usize;
+ assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}");
}
}
}
@@ -6515,7 +6513,8 @@ fn reading_colors_workdir() -> PathBuf {
dir
}
-/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels.
+/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages
+/// come as RGB; their alpha is 255.
fn png_pixels(content: &Content) -> Vec {
let Content::Png { data, width, height } = content else { panic!("expected a PNG") };
assert_eq!((*width, *height), (200, 300));
@@ -6523,7 +6522,11 @@ fn png_pixels(content: &Content) -> Vec {
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
assert_eq!((info.width, info.height), (200, 300));
- pixels
+ match info.color_type {
+ png::ColorType::Rgba => pixels,
+ png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
+ other => panic!("unexpected colour type {other:?}"),
+ }
}
/// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image.
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
index 0bf4e2f79d..a1820e2d99 100644
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -138,23 +138,48 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image
}
}
-/// Premultiplied RGBA → PNG (straight alpha).
+/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered
+/// page always is one: it is drawn on white) is written as RGB.
+///
+/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the
+/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at
+/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms
+/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual
+/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB).
pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> {
- let mut rgba = premultiplied.to_vec();
- for px in rgba.as_chunks_mut::<4>().0 {
- let a = u32::from(px[3]);
- if a != 0 && a != 255 {
- for c in &mut px[..3] {
- *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
+ // Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel,
+ // and the opaque path would write the rest as a valid image.
+ let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4));
+ if expected != Some(premultiplied.len()) {
+ return Err(match expected {
+ Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()),
+ None => format!("a {width}×{height} raster is too large"),
+ });
+ }
+ let pixels = premultiplied.as_chunks::<4>().0;
+ let opaque = pixels.iter().all(|px| px[3] == 255);
+ let (color, data) = if opaque {
+ (png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::>())
+ } else {
+ let mut rgba = premultiplied.to_vec();
+ for px in rgba.as_chunks_mut::<4>().0 {
+ let a = u32::from(px[3]);
+ if a != 0 && a != 255 {
+ for c in &mut px[..3] {
+ *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
+ }
}
}
- }
+ (png::ColorType::Rgba, rgba)
+ };
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, width, height);
- enc.set_color(png::ColorType::Rgba);
+ enc.set_color(color);
enc.set_depth(png::BitDepth::Eight);
+ enc.set_deflate_compression(png::DeflateCompression::Level(3));
+ enc.set_filter(png::Filter::Adaptive);
let mut w = enc.write_header().map_err(|e| e.to_string())?;
- w.write_image_data(&rgba).map_err(|e| e.to_string())?;
+ w.write_image_data(&data).map_err(|e| e.to_string())?;
w.finish().map_err(|e| e.to_string())?;
Ok(out)
}
@@ -260,3 +285,54 @@ mod image_name_tests {
assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png");
}
}
+
+#[cfg(test)]
+mod png_tests {
+ use super::encode_png;
+
+ /// The colour type and pixels of a PNG.
+ fn decode(png: &[u8]) -> (png::ColorType, Vec) {
+ let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
+ let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
+ let frame = reader.next_frame(&mut pixels).unwrap();
+ pixels.truncate(frame.buffer_size());
+ (frame.color_type, pixels)
+ }
+
+ /// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as
+ /// rendered (the alpha channel would only make the file bigger and slower to write).
+ #[test]
+ fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() {
+ let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]];
+ let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap());
+ assert_eq!(color, png::ColorType::Rgb);
+ assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::>());
+ }
+
+ /// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied.
+ #[test]
+ fn translucent_rasters_keep_straight_alpha() {
+ let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]];
+ let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap());
+ assert_eq!(color, png::ColorType::Rgba);
+ assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]);
+ }
+
+ /// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The
+ /// opaque path used to drop a partial trailing pixel and write the rest as a valid image.
+ #[test]
+ fn rasters_of_the_wrong_length_are_rejected() {
+ let cases: [(&str, u32, u32, &[u8]); 7] = [
+ ("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]),
+ ("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]),
+ ("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]),
+ ("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]),
+ ("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]),
+ ("empty", 1, 1, &[]),
+ ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]),
+ ];
+ for (what, width, height, data) in cases {
+ assert!(encode_png(width, height, data).is_err(), "{what}");
+ }
+ }
+}
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 5d73511e9c..ed56c8aeaa 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -19,6 +19,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **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-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; 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 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; 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 (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)
@@ -222,6 +223,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. |
| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. |
| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. |
+| 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. |
| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged |
| 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. |
From 583c1ec68c75024d77066983a79c05f27141f820 Mon Sep 17 00:00:00 2001
From: abc2cb
Date: Thu, 22 Oct 2026 00:34:16 +0300
Subject: [PATCH 12/18] M3: the mode bar has no Read tab, as in Acrobat (#2240)
* M3: the mode bar has no Read tab, as in Acrobat
Acrobat's mode bar is All tools, Edit, Convert and E-Sign: reading is what
the viewer does with no tool in use. Read mode (the panes hidden) stays a
View command and a default workspace. Tests that clicked the Read tab as an
ordinary mode-bar button use Convert instead.
Co-Authored-By: Claude Opus 5.5
* M3: parity notes for the mode bar without a Read tab
Co-Authored-By: Claude Opus 5.5
* M3: the Read-mode tests enter through the View command
The mode bar no longer has a Read tab, so `read_mode_hides_the_quick_toolbar`
(ui.rs) and `switching_modes_keeps_the_current_page_at_fixed_zoom` (view.rs)
clicked a button that is gone, failing the test jobs on ubuntu-latest and
windows-latest. Both run `view.read_mode` now, as the other read-mode tests in
this PR do; the second is the test the `view.read-mode` parity entry cites.
`cargo test -p pdfcraft-ui-egui --test ui --test view`: 56 and 55 passed.
---------
Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
---
crates/ui-egui/src/chrome.rs | 6 ++--
crates/ui-egui/tests/control.rs | 62 ++++++++++++++++++---------------
crates/ui-egui/tests/ui.rs | 21 +++++++++--
crates/ui-egui/tests/view.rs | 2 +-
parity/acrobat-features.toml | 4 +--
5 files changed, 57 insertions(+), 38 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index e01d752c40..48d1d3c44c 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -500,9 +500,9 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
ui.add_space(10.0);
- for (mode, label) in
- [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]
- {
+ // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
+ // in use. Read mode (the panes hidden) stays a View command and a default workspace.
+ for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() {
app.switch_mode(mode, true);
}
diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs
index e6af166d77..20fa4cb230 100644
--- a/crates/ui-egui/tests/control.rs
+++ b/crates/ui-egui/tests/control.rs
@@ -119,26 +119,28 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() {
#[test]
fn control_focus_traverses_real_widgets_and_keyboard_activates() {
let (mut h, c) = harness();
- ok(&mut h, &c, "ui.focus", json!({"label": "Read"}));
- let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}));
- assert_eq!(read["widgets"][0]["focused"], true, "{read}");
- assert_eq!(read["widgets"][0]["focusable"], true);
+ ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
+ let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
+ let focused =
+ |found: &serde_json::Value, label: &str| found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label && w["focused"] == true);
+ assert!(focused(&convert, "Convert"), "{convert}");
+ assert!(convert["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focusable"] == true));
ok(&mut h, &c, "ui.key", json!({"key": "Tab"}));
- let edit = ok(&mut h, &c, "ui.inspect", json!({"query": "Edit", "role": "Button"}));
- assert!(edit["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Edit" && w["focused"] == true), "{edit}");
+ let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"}));
+ assert!(focused(&sign, "E-Sign"), "{sign}");
ok(&mut h, &c, "ui.key", json!({"key": "Tab", "modifiers": ["shift"]}));
- let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}));
- assert_eq!(read["widgets"][0]["focused"], true);
+ let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
+ assert!(focused(&convert, "Convert"), "{convert}");
ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
let state = ok(&mut h, &c, "ui.state", json!({}));
- assert_eq!(state["mode"], "Read");
+ assert_eq!(state["mode"], "Convert");
assert_eq!(state["documents"][0]["dirty"], false);
}
#[test]
fn control_button_focus_preserves_document_undo_redo_shortcuts() {
let (mut h, c) = harness_pages(2);
- ok(&mut h, &c, "ui.focus", json!({"label": "Read"}));
+ ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
ok(&mut h, &c, "ui.click", json!({"label": "All tools"}));
assert!(h.ctx.egui_wants_keyboard_input());
@@ -176,9 +178,9 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() {
json!({"label": "does not exist"}),
json!({"id": "not a number"}),
json!({"id": 0}),
- json!({"id": 1, "label": "Read"}),
+ json!({"id": 1, "label": "Convert"}),
json!({"label": false}),
- json!({"label": "Read", "force": true}),
+ json!({"label": "Convert", "force": true}),
json!([]),
] {
assert!(call(&mut h, &c, "ui.focus", params).is_err());
@@ -186,9 +188,10 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() {
let widgets = ok(&mut h, &c, "ui.inspect", json!({}));
let nonfocusable = widgets["widgets"].as_array().unwrap().iter().find(|w| w["focusable"] == false).unwrap();
assert!(call(&mut h, &c, "ui.focus", json!({"id": nonfocusable["id"]})).is_err());
- let read = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read").unwrap();
- ok(&mut h, &c, "ui.focus", json!({"id": read["id"]}));
- assert_eq!(ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}))["widgets"][0]["focused"], true);
+ let convert = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert").unwrap();
+ ok(&mut h, &c, "ui.focus", json!({"id": convert["id"]}));
+ let found = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
+ assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focused"] == true), "{found}");
// Home has no active document, so its document-only toolbar controls are disabled.
ok(&mut h, &c, "ui.click", json!({"label": "Home"}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["home"], true);
@@ -740,8 +743,8 @@ fn japanese_controls_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja");
- ok(&mut h, &c, "ui.click", json!({ "label": "閲覧" }));
- assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
+ ok(&mut h, &c, "ui.click", json!({ "label": "変換" }));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" }));
@@ -766,8 +769,8 @@ fn japanese_controls_and_search_keep_command_ids() {
fn german_preferences_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" }));
- ok(&mut h, &c, "ui.click", json!({ "label": "Lesen" }));
- assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
+ ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" }));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" }));
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
@@ -958,23 +961,24 @@ fn simplified_chinese_signature_prompts_and_errors_keep_document_state() {
#[test]
fn inspect_and_click_by_label_and_id() {
let (mut h, c) = harness();
- let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "read" }));
- let read = found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read" && w["clickable"] == true).cloned().expect("a Read tab");
- let rect = read["rect"].as_array().unwrap();
+ let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "convert" }));
+ let convert =
+ found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert" && w["clickable"] == true).cloned().expect("a Convert tab");
+ let rect = convert["rect"].as_array().unwrap();
assert!(rect[2].as_f64().unwrap() > rect[0].as_f64().unwrap());
- ok(&mut h, &c, "ui.click", json!({ "label": "Read" }));
- assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
+ ok(&mut h, &c, "ui.click", json!({ "label": "Convert" }));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
let all = ok(&mut h, &c, "ui.inspect", json!({ "query": "all tools" }));
let id = all["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "All tools" && w["clickable"] == true).unwrap()["id"].clone();
ok(&mut h, &c, "ui.click", json!({ "id": id }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
- // Clicking a point works too (here: the Read tab's centre).
+ // Clicking a point works too (here: the Convert tab's centre).
let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| rect[i].as_f64().unwrap());
ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 }));
- assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
let err = call(&mut h, &c, "ui.click", json!({ "label": "No such button" })).unwrap_err();
assert!(err.contains("no enabled clickable widget"), "{err}");
@@ -1622,10 +1626,10 @@ fn click_and_drag_refuse_coordinates_that_overflow_like_move() {
let e = call(&mut h, &c, method, params.clone()).expect_err(&format!("{method} {params} is refused"));
assert!(e.starts_with(&format!("{method}: ")) && e.contains("finite"), "{e}");
h.run_steps(4);
- assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["count"], 1);
+ assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["count"], 1);
}
- let read = ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["widgets"][0]["rect"].clone();
- let (x, y) = (read[0].as_f64().unwrap() + 4.0, read[1].as_f64().unwrap() + 4.0);
+ let convert = ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["widgets"][0]["rect"].clone();
+ let (x, y) = (convert[0].as_f64().unwrap() + 4.0, convert[1].as_f64().unwrap() + 4.0);
assert_eq!(ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }))["clicked"], json!([x, y]));
ok(&mut h, &c, "ui.drag", json!({ "from": [x, y], "to": [x + 10.0, y], "steps": 2 }));
}
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
index 118dee8f74..a74e588790 100644
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -192,6 +192,21 @@ fn read_mode_rail_buttons_open_their_panels() {
h.get_by_label("Alpha section");
}
+/// The mode bar matches Acrobat's: All tools, Edit, Convert and E-Sign, with no Read tab. Read
+/// mode is still a View command.
+#[test]
+fn the_mode_bar_has_no_read_tab() {
+ use pdfcraft_ui_egui::Mode;
+ let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
+ for label in ["All tools", "Edit", "Convert", "E-Sign"] {
+ assert!(h.query_all_by_label(label).next().is_some(), "the {label} tab is there");
+ }
+ assert!(h.query_all_by_label("Read").next().is_none(), "no Read tab");
+ h.state_mut().run_command("view.read_mode");
+ h.run_steps(2);
+ assert_eq!(h.state().mode, Mode::Read);
+}
+
/// A document that asks to open in Full Screen mode (`/PageMode /FullScreen`) is asked about
/// first, as in Acrobat: "Not now" leaves the window as it is, "Full Screen" enters it.
#[test]
@@ -227,7 +242,7 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
- h.get_by_label("Read").click();
+ h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert_eq!(h.state().mode, Mode::Read);
assert_eq!(h.state().right, None);
@@ -254,7 +269,7 @@ fn read_mode_hides_the_quick_toolbar() {
h.get_by_label("Hand (H)").click();
h.run_steps(2);
assert_eq!(h.state().quick_tool, QuickTool::Hand);
- h.get_by_label("Read").click();
+ h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert!(h.query_by_label("Select (V)").is_none(), "no quick toolbar in Read mode");
assert_eq!(h.state().quick_tool, QuickTool::Select);
@@ -286,7 +301,7 @@ fn a_pane_closed_in_read_mode_stays_closed_when_leaving_it() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
- h.get_by_label("Read").click();
+ h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert_eq!(h.state().right, None);
// Open Comments from the rail, then close it again.
diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs
index 9cf99021a3..39ea83df76 100644
--- a/crates/ui-egui/tests/view.rs
+++ b/crates/ui-egui/tests/view.rs
@@ -1594,7 +1594,7 @@ fn switching_modes_keeps_the_current_page_at_fixed_zoom() {
h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move));
h.run_steps(8);
let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
- h.get_by_label("Read").click();
+ h.state_mut().run_command("view.read_mode");
h.run_steps(8);
assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page");
let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 7aca7d5c8b..6caa46d3a2 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -1094,8 +1094,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.read_mode"]
tools = []
-evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"]
-notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position"
+evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/ui.rs::the_mode_bar_has_no_read_tab", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"]
+notes = "View ▸ Read mode (and the Read mode workspace) hides the panes and tool chrome. The mode bar has no Read tab, as in Acrobat, where reading is the viewer with no tool in use: its tabs are All tools, Edit, Convert and E-Sign. Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position"
[[feature]]
id = "view.full-screen"
From d90cee4669433df2288cabbd4ac7b0894643c3ea Mon Sep 17 00:00:00 2001
From: Piohrun
Date: Thu, 22 Oct 2026 00:01:35 +0200
Subject: [PATCH 13/18] fix(render): read each object stream's index once
(vendored hayro-syntax patch 22) (#2427)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(render): read each object stream's index once (vendored hayro-syntax patch 22)
`XRef::get_with` parsed an object stream's whole index of `objnum offset`
pairs again for every object it fetched from that stream, so reading all of a
stream of N objects took N² steps. Firefox keeps every object of a page in one
stream: the pdf.js corpus file bug1978317.pdf (65,542 objects in one stream)
took 37 s to draw and 40 s to extract its text, and was a known timeout in the
robustness baseline.
The parsed index is now kept per stream, beside the decoded stream data it
indexes (`ObjectStreamIndexes`), and never cleared, as that data isn't. A
malformed index is remembered as malformed; `/N` or `/First` that can't be
read (a reference that can't be followed while another stream loads) is not
remembered. An offset past `usize` is malformed instead of wrapping. A poisoned
lock is still used: entries are inserted whole.
Release build, Linux: bug1978317.pdf draws in 0.5 s (was 37 s) and extracts
its text in 1.9 s (was 40 s), and leaves the baseline's known timeouts;
40,000 pages in one stream open in 0.2 s (was 54 s).
`raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`
times out at 20 s with the fix reverted and passes in 0.2 s with it.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* test(render): an object stream offset past usize is malformed, not a panic
Review follow-up on #2427. Mutation testing by mtlcl410 found that replacing
patch (22)'s `checked_add` in `read_object_stream_index` with a plain `+`
survived every test, although in a debug build that `+` panics on the very
input it guards against (and wraps in a release build). Their test pins it:
it fails with "attempt to add with overflow" under that mutant and passes on
this branch.
Co-Authored-By: Claude Opus 5.5
---------
Co-authored-by: Claude Opus 5.5
---
ROADMAP.md | 1 +
crates/render/src/raster.rs | 82 ++++++++++++++++++++++
docs/roadmap.md | 2 +
vendor/README.md | 2 +-
vendor/hayro-syntax/src/xref.rs | 119 +++++++++++++++++++++++++++-----
xtask/baselines/pdfjs.json | 1 -
6 files changed, 188 insertions(+), 19 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index b910ce0b62..49a8b27cc9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -216,6 +216,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
+- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index c10ab74d15..d4e054cf71 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -4612,6 +4612,88 @@ trailer << /Root 1 0 R >>
pdf
}
+ /// A document whose `pages` page dictionaries all live in one (uncompressed) object stream,
+ /// found through a cross-reference stream: every page shows the content `0 0 1 rg 10 10 80 80
+ /// re f` on a 100 pt square, except the last, which is 50 × 100 pt.
+ fn pages_in_one_object_stream(pages: usize) -> Vec {
+ // 1 catalog, 2 page tree, 3 content, 4 the object stream, 5.. the pages, then the xref.
+ let page = |n: usize| {
+ let size = if n + 1 == pages { "50 100" } else { "100 100" };
+ format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size}] /Contents 3 0 R >>\n")
+ };
+ let (mut index, mut body) = (String::new(), String::new());
+ for n in 0..pages {
+ index.push_str(&format!("{} {} ", n + 5, body.len()));
+ body.push_str(&page(n));
+ }
+ index.push('\n');
+ let kids: Vec = (0..pages).map(|n| format!("{} 0 R", n + 5)).collect();
+ let content = "0 0 1 rg 10 10 80 80 re f";
+ let mut pdf = b"%PDF-1.5\n".to_vec();
+ let mut offsets = Vec::new();
+ for (num, obj) in [
+ (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string()),
+ (2, format!("<< /Type /Pages /Kids [{}] /Count {pages} >>", kids.join(" "))),
+ (3, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())),
+ (
+ 4,
+ format!(
+ "<< /Type /ObjStm /N {pages} /First {} /Length {} >>\nstream\n{index}{body}\nendstream",
+ index.len(),
+ index.len() + body.len()
+ ),
+ ),
+ ] {
+ offsets.push(pdf.len());
+ pdf.extend_from_slice(format!("{num} 0 obj\n{obj}\nendobj\n").as_bytes());
+ }
+ // Cross-reference stream rows of /W [1 4 4]: free 0, objects 1–4 in the file, the pages
+ // at their index in object 4, and the cross-reference stream itself.
+ let xref_num = pages + 5;
+ let xref_at = pdf.len();
+ let mut rows = vec![0u8, 0, 0, 0, 0, 0, 0, 0xff, 0xff];
+ let row = |kind: u8, a: usize, b: usize| [[kind].as_slice(), &(a as u32).to_be_bytes(), &(b as u32).to_be_bytes()].concat();
+ for at in &offsets {
+ rows.extend(row(1, *at, 0));
+ }
+ for n in 0..pages {
+ rows.extend(row(2, 4, n));
+ }
+ rows.extend(row(1, xref_at, 0));
+ pdf.extend_from_slice(
+ format!("{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 4 4] /Root 1 0 R /Length {} >>\nstream\n", xref_num + 1, rows.len()).as_bytes(),
+ );
+ pdf.extend_from_slice(&rows);
+ pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes());
+ pdf
+ }
+
+ /// An object stream's index of `objnum offset` pairs is read once, not for every object
+ /// fetched from it (vendored hayro-syntax patch 22). Read again each time, all of a stream of
+ /// N objects took N² steps: a Firefox page keeping 65,542 objects in one stream took 37 s to
+ /// draw and 40 s to extract its text. These 40,000 pages in one stream took about a minute
+ /// to open that way; now a fraction of a second. The last page, at the end of the index,
+ /// still has its own size.
+ #[test]
+ fn objects_in_one_large_object_stream_are_read_in_linear_time() {
+ const PAGES: usize = 40_000;
+ let pdf = pages_in_one_object_stream(PAGES);
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
+ let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ let last = r.render(RenderRequest { page: PAGES - 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ let _ = tx.send((first, last));
+ });
+ let (first, last) =
+ rx.recv_timeout(std::time::Duration::from_secs(20)).expect("reading every object of one object stream must not take quadratic time");
+ assert!(first.error.is_none(), "{:?}", first.error);
+ assert_eq!((first.width, first.height), (100, 100));
+ assert_eq!(rgb_at(&first.rgba, first.width as usize, 50, 50), [0, 0, 255]);
+ assert!(last.error.is_none(), "{:?}", last.error);
+ assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset");
+ }
+
/// A page whose content expands far beyond its compressed stream stops at the expansion budget,
/// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so.
#[test]
diff --git a/docs/roadmap.md b/docs/roadmap.md
index ed56c8aeaa..c10d91d20f 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -11,6 +11,7 @@
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; 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 (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)
@@ -221,6 +222,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. |
| 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. |
| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. |
+| 2026-10-15 | minor | M2: an object stream's index is parsed once per stream (hayro-syntax patch 22), not once per object fetched from it; a page whose 65,542 objects share one stream draws in 0.5 s instead of 37 s. Estimates unchanged. |
| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. |
| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. |
| 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. |
diff --git a/vendor/README.md b/vendor/README.md
index 824e84419e..57bdd516c7 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -17,7 +17,7 @@ In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device spac
| 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; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (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`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` |
| 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.; (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; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306) | (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`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`)
+| 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; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text) | (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`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`
| 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, RunLength and ASCII85 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`
| 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` |
diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs
index 20e33a4104..673d516d21 100644
--- a/vendor/hayro-syntax/src/xref.rs
+++ b/vendor/hayro-syntax/src/xref.rs
@@ -289,6 +289,28 @@ impl XRef {
Some(f(&mut cache))
}
+ /// PdfCraft patch: (hayro-syntax (22)) run `f` on this PDF's parsed object stream indexes
+ /// ([`ObjectStreamIndexes`]). `None` when there is none to use (the dummy xref, or a no_std
+ /// cache already borrowed), and the caller then parses the index without it. A poisoned lock
+ /// is still used: entries are complete `Arc`s inserted in one step, so a panic elsewhere
+ /// cannot have left one half-written.
+ fn with_object_stream_indexes(
+ &self,
+ f: impl FnOnce(&mut ObjectStreamIndexes) -> T,
+ ) -> Option {
+ let Inner::Some(repr) = &self.0 else {
+ return None;
+ };
+ #[cfg(feature = "std")]
+ let mut indexes = repr
+ .object_stream_indexes
+ .lock()
+ .unwrap_or_else(|poison| poison.into_inner());
+ #[cfg(not(feature = "std"))]
+ let mut indexes = repr.object_stream_indexes.try_borrow_mut().ok()?;
+ Some(f(&mut indexes))
+ }
+
fn new(
data: PdfData,
xref_map: XrefMap,
@@ -310,6 +332,7 @@ impl XRef {
trailer_data,
password: password.to_vec(),
dict_cache: Arc::new(Mutex::new(DictCache::default())),
+ object_stream_indexes: Arc::new(Mutex::new(ObjectStreamIndexes::default())),
})));
// We read the trailer twice, once to determine the encryption used and then a second
@@ -622,10 +645,35 @@ impl XRef {
return None;
}
- let stream = self.get_with::>(obj_stream_id, &ctx)?;
+ // PdfCraft patch: (hayro-syntax (22)) the stream's index of `objnum offset` pairs
+ // is parsed once and kept, as its decoded data is: parsed again for every object
+ // fetched from it, reading all of a stream of N objects took N² steps (one
+ // Firefox page kept 65,542 objects in one stream and took 37 s to draw).
let data = repr.data.get_with(obj_stream_id, &ctx)?;
- let object_stream = ObjectStream::new(stream, data, &ctx)?;
- object_stream.get(index)
+ let known = self
+ .with_object_stream_indexes(|indexes| indexes.get(&obj_stream_id).cloned())
+ .flatten();
+ let offsets = match known {
+ // A stream whose pairs don't parse stays unreadable, as it was.
+ Some(offsets) => offsets?,
+ None => {
+ let stream = self.get_with::>(obj_stream_id, &ctx)?;
+ // `/N` and `/First` may be references that can't be followed while
+ // another stream loads: reading them fails without being remembered.
+ let num_objects = stream.dict().get::(N)?;
+ let first_offset = stream.dict().get::(FIRST)?;
+ let parsed =
+ read_object_stream_index(num_objects, first_offset, data).map(Arc::new);
+ self.with_object_stream_indexes(|indexes| {
+ indexes
+ .entry(obj_stream_id)
+ .or_insert_with(|| parsed.clone())
+ .clone()
+ })
+ .unwrap_or(parsed)?
+ }
+ };
+ ObjectStream::with_offsets(data, &ctx, offsets).get(index)
}
}
}
@@ -698,6 +746,7 @@ impl TrailerData {
#[derive(Debug, Clone)]
struct SomeRepr {
dict_cache: Arc>,
+ object_stream_indexes: Arc>,
data: Arc,
map: Arc>,
metadata: Arc,
@@ -1067,34 +1116,62 @@ fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result>>>;
+
+/// The `(object number, offset)` pairs at the start of an object stream's decoded `data`, the
+/// offsets measured from the start of `data`. `None` when the pairs don't parse. Reads no
+/// further than the data holds, whatever `/N` claims.
+fn read_object_stream_index(
+ num_objects: usize,
+ first_offset: usize,
+ data: &[u8],
+) -> Option> {
+ let mut r = Reader::new(data);
+
+ let mut offsets = vec![];
+
+ for _ in 0..num_objects {
+ r.skip_white_spaces_and_comments();
+ // Skip object number
+ let obj_num = r.read_without_context::()?;
+ r.skip_white_spaces_and_comments();
+ let relative_offset = r.read_without_context::()?;
+ // PdfCraft patch: (hayro-syntax (22)) an offset past `usize` is malformed (it wrapped).
+ offsets.push((obj_num, first_offset.checked_add(relative_offset)?));
+ }
+
+ Some(offsets)
+}
+
struct ObjectStream<'a> {
data: &'a [u8],
ctx: ReaderContext<'a>,
- offsets: Vec<(u32, usize)>,
+ offsets: Arc>,
}
impl<'a> ObjectStream<'a> {
fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option {
let num_objects = inner.dict().get::(N)?;
let first_offset = inner.dict().get::(FIRST)?;
+ let offsets = read_object_stream_index(num_objects, first_offset, data)?;
- let mut r = Reader::new(data);
-
- let mut offsets = vec![];
-
- for _ in 0..num_objects {
- r.skip_white_spaces_and_comments();
- // Skip object number
- let obj_num = r.read_without_context::()?;
- r.skip_white_spaces_and_comments();
- let relative_offset = r.read_without_context::()?;
- offsets.push((obj_num, first_offset + relative_offset));
- }
+ Some(Self::with_offsets(data, ctx, Arc::new(offsets)))
+ }
+ /// PdfCraft patch: (hayro-syntax (22)) an object stream whose index is already parsed.
+ fn with_offsets(
+ data: &'a [u8],
+ ctx: &ReaderContext<'a>,
+ offsets: Arc>,
+ ) -> Self {
let mut ctx = ctx.clone();
ctx.set_in_object_stream(true);
- Some(Self { data, ctx, offsets })
+ Self { data, ctx, offsets }
}
fn get(&self, index: u32) -> Option
@@ -1369,4 +1446,12 @@ mod tests {
let mut reader = Reader::new(data);
assert!(read_xref_table_trailer(&mut reader, &ReaderContext::dummy()).is_none());
}
+
+ /// PdfCraft patch: (hayro-syntax (22)) an object's offset past `usize` is malformed: a plain
+ /// `+` there panics in a debug build and wraps in a release one.
+ #[test]
+ fn an_object_stream_offset_past_usize_is_malformed_not_a_panic() {
+ assert_eq!(read_object_stream_index(1, usize::MAX, b"5 1"), None);
+ assert_eq!(read_object_stream_index(1, 10, b"5 1"), Some(vec![(5, 11)]));
+ }
}
diff --git a/xtask/baselines/pdfjs.json b/xtask/baselines/pdfjs.json
index a441ad8722..212da3373b 100644
--- a/xtask/baselines/pdfjs.json
+++ b/xtask/baselines/pdfjs.json
@@ -4,7 +4,6 @@
"auth-event-ef-open.pdf": "open-error",
"bug1020226.pdf": "open-error",
"bug1782186.pdf": "open-error",
- "bug1978317.pdf": "timeout",
"encrypted-attachment.pdf": "open-error",
"issue15590.pdf": "open-error",
"issue15893_reduced.pdf": "open-error",
From 18b61e240185cff77f93ab4afbabd1deb0c28984 Mon Sep 17 00:00:00 2001
From: abc2cb
Date: Thu, 22 Oct 2026 01:06:54 +0300
Subject: [PATCH 14/18] M4: Organize pages remembers its page size, as Acrobat
does (#2282)
The grid's size was kept per document view and reset to 100% for every document and every
start. Acrobat keeps the size last chosen, observed in its Organize Pages. It is now one value
for the app, saved with the other settings like Combine's zoom, and --grid-zoom sets it too.
A damaged saved value is ignored or clamped to 50-300%.
Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
---
crates/ui-egui/src/canvas.rs | 4 ++++
crates/ui-egui/src/lib.rs | 11 +++++++++++
crates/ui-egui/tests/editing.rs | 33 +++++++++++++++++++++++++++++++++
parity/acrobat-features.toml | 4 ++--
4 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index eb9244a284..62b29fea80 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2433,9 +2433,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
// No dialog, close prompt or palette over the page: only then does page input count.
let unobstructed = !app.modal_open() && !app.palette_open;
+ let organize_zoom = app.organize_zoom;
let view = &mut app.views[index];
if view.organize {
+ // One remembered grid size for every document, kept in the settings.
+ view.set_grid_zoom(organize_zoom);
organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t);
+ app.organize_zoom = view.grid_zoom();
return;
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 9db9247591..34e7f4b614 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -698,6 +698,9 @@ pub struct PdfCraftApp {
/// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set
/// with [`PdfCraftApp::set_combine_zoom`].
pub combine_zoom: f32,
+ /// How large Organize pages draws its grid, for every document (1.0 = the usual; kept in
+ /// the settings).
+ pub organize_zoom: f32,
/// The Combine grid's thumbnails: rendered off the UI thread, a few at a time.
pub(crate) combine_thumbs: combine_grid::Thumbs,
/// Images waiting for the resolution choice (released on cancel).
@@ -1013,6 +1016,7 @@ impl PdfCraftApp {
combine_columns: Default::default(),
combine_view: Default::default(),
combine_zoom: 1.0,
+ organize_zoom: 1.0,
combine_thumbs: Default::default(),
image_import: None,
custom_stamps: Vec::new(),
@@ -2309,6 +2313,7 @@ impl PdfCraftApp {
v["redact_prefs"] = self.redact_defaults.to_json();
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
+ v["organize_zoom"] = serde_json::json!(self.organize_zoom);
v.to_string()
}
@@ -2331,6 +2336,10 @@ impl PdfCraftApp {
if let Some(zoom) = v["combine_zoom"].as_f64() {
self.set_combine_zoom(zoom as f32);
}
+ // Clamped like any grid zoom when the grid next shows; nonsense keeps the usual size.
+ if let Some(zoom) = v["organize_zoom"].as_f64().map(|z| z as f32).filter(|z| z.is_finite()) {
+ self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end());
+ }
if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) {
// Only keep entries whose files still exist.
#[cfg(not(target_arch = "wasm32"))]
@@ -2641,6 +2650,8 @@ impl PdfCraftApp {
return Err("grid-zoom must be between 50 and 300".into());
}
v.set_grid_zoom(percent / 100.0);
+ // The grid's size is one for every document, as it is remembered.
+ self.organize_zoom = v.grid_zoom();
}
("rotate", Some(v)) => {
let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?;
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index 1890c1c320..cced4fe03d 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -1880,6 +1880,39 @@ fn page_grid_zooms_with_its_buttons_keys_and_pinch() {
assert!(!dirty(&h), "zooming is not an edit");
}
+/// As in Acrobat, Organize pages keeps the size last chosen: every document's grid opens at it,
+/// and so does the next start of the app.
+#[test]
+fn page_grid_size_is_remembered_for_every_document_and_restart() {
+ let mut h = organize(3);
+ h.get_by_label("Larger pages").click();
+ h.run_steps(3);
+ assert_eq!(grid_zoom(&h), 1.25);
+ h.state_mut().open_bytes("other.pdf", None, fixture(2)).expect("second fixture opens");
+ h.state_mut().set_option("organize", "on").unwrap();
+ h.run_steps(4);
+ let other = h.state().active.expect("the new document is active");
+ assert_ne!(other, 0);
+ assert_eq!(h.state().views[other].grid_zoom(), 1.25, "the other document");
+ let settings = h.state().persist();
+ let h = harness(3, move |app| {
+ app.restore(&settings);
+ app.set_option("organize", "on").unwrap();
+ });
+ assert_eq!(grid_zoom(&h), 1.25, "after a restart");
+}
+
+#[test]
+fn a_damaged_remembered_page_grid_size_is_ignored_or_clamped() {
+ let mut app = PdfCraftApp::new();
+ app.restore(r#"{"organize_zoom": "huge"}"#);
+ assert_eq!(app.organize_zoom, 1.0);
+ app.restore(r#"{"organize_zoom": 40.0}"#);
+ assert_eq!(app.organize_zoom, 3.0);
+ app.restore(r#"{"organize_zoom": -2}"#);
+ assert_eq!(app.organize_zoom, 0.5);
+}
+
#[test]
fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() {
// A long document: sharpness must not depend on how many pages there are.
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 6caa46d3a2..95443d57df 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -2884,8 +2884,8 @@ milestone = "M4"
status = "shipped"
commands = ["page.organize"]
tools = []
-evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"]
-notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control."
+evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"]
+notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing: a slider as in Acrobat; the Pages side panel has no size control."
[[feature]]
id = "organize.split-every-n"
From 193754502f03d05d8f03c36f8df0dfa99e861189 Mon Sep 17 00:00:00 2001
From: abc2cb
Date: Thu, 22 Oct 2026 01:11:05 +0300
Subject: [PATCH 15/18] M3: the Tools panel has no tab strip while docked alone
(#2244)
A "Tools" tab sat over the tool panel although nothing else shared its
group; Acrobat's tool pane has no such strip. Tools docked on its own is
now drawn bare. Grouped with another panel, or floating, it keeps its
tab so it can be dragged away again, and its side stays a preference
and a dock command. The docking test groups Inspector with Tools by
command, since there is no tab to drop on.
Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
---
crates/ui-egui/src/docking.rs | 7 ++++++-
crates/ui-egui/tests/control.rs | 16 +++++-----------
2 files changed, 11 insertions(+), 12 deletions(-)
diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
index 58fd71a678..4531709046 100644
--- a/crates/ui-egui/src/docking.rs
+++ b/crates/ui-egui/src/docking.rs
@@ -396,12 +396,17 @@ pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) {
// Availability is a render projection: the saved group, selection and width survive Home.
let _ = rendered.apply(Action::Close { panel: Panel::Inspector });
}
+ // Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped
+ // with another panel, or floating, it keeps its tab so it can be dragged away again; moving it
+ // to the other side stays a preference and a command.
+ let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_)));
+ let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) };
let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits(
ui,
&rendered,
&style,
|panel| crate::i18n::t(panel.label()).into(),
- permissions,
+ render_permissions,
panel_limits,
|ui, panel| match panel {
Panel::Canvas => {
diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs
index 20fa4cb230..7de5c59997 100644
--- a/crates/ui-egui/tests/control.rs
+++ b/crates/ui-egui/tests/control.rs
@@ -1299,19 +1299,13 @@ fn docking_pointer_group_float_close_restore_and_document_commands() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
h.run_steps(4);
- let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").rect().center();
- let to = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
- h.event(egui::Event::PointerMoved(from));
- h.run_steps(2);
- h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
- h.run_steps(2);
- for step in 1..=8 {
- h.event(egui::Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
- h.run_steps(1);
- }
- h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
+ // Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command.
+ assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none());
+ ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
h.run_steps(4);
assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools));
+ // Grouped, Tools has its tab again.
+ h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools");
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Float panel").click();
From 6dec57bf809a386df2d8fad89830749786e8414f Mon Sep 17 00:00:00 2001
From: abc2cb
Date: Thu, 22 Oct 2026 01:26:28 +0300
Subject: [PATCH 16/18] M7: lay out Format text as Acrobat's panel (#2249)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* M7: lay out Format text as Acrobat's panel
Format text was a row of separate combos, a Load font button, an
Installed fonts combo, a swatch grid and labelled drag values. It now
follows Acrobat's arrangement: the font across the panel (the page's
fonts, the standard families, Load font… and Installed fonts in one
list, showing the paragraph's own font by name); size and a colour
swatch, then lists and an alignment flyout; bold, italic, underline,
superscript and subscript; line and paragraph spacing; horizontal scale
and character spacing. One panel serves an edited paragraph and added
text. What PdfCraft can't apply yet (lists, superscript and subscript,
paragraph spacing, spacing for added text) is shown disabled and says
it is planned.
Icons stay Lucide's: superscript, subscript, pilcrow and
move-horizontal are added from lucide-static 1.49.0 with attribution.
Eight new strings are translated in every catalogue.
Co-Authored-By: Claude Opus 5.5
* M7: parity notes for the Format text panel
Co-Authored-By: Claude Opus 5.5
* i18n(vi): Superscript and Subscript for Format text
The Vietnamese catalogue came from main without the Format text panel's
Superscript and Subscript labels; vietnamese_covers_ui_literals lists them.
Co-Authored-By: Claude Opus 5.5
* M7: Format text takes any horizontal scale and character spacing again
Review of #2249: the panel's preset lists narrowed what could be set to
50-200 % and -1..5 pt, where the old fields took 10-400 % and -5..50 pt.
Each now has a number field over the old range again, with the list of
common values beside it.
Test: the_format_panel_takes_any_scale_and_character_spacing types values
between the listed ones and beyond them, saves and reopens, and compares
the paragraph with the engine's own rewrite. It fails without the field.
Co-Authored-By: Claude Opus 5.5
* M7: Format text's rows line their controls up and fit the panel
Raised in #3037: the size list sat lower than the colour swatch and the
alignment button beside it. egui lays a list out from the default control
height and grows it downward, so a row's shorter controls ended up off its
middle. Each row is now one control height and centres what it holds, at
a list's own height.
The spacing rows' columns are measured from what they hold, so the second
list no longer runs past the panel; the number fields' lists open from a
small arrow, which leaves the field the room its value needs.
Test: format_text_rows_line_up_and_fit_the_panel checks the size list is
centred with the swatch and alignment button and as tall as the font list,
that each spacing row shares one line, and that nothing passes the panel.
It fails without the change.
Co-Authored-By: Claude Opus 5.5
* Format text: choosing the family a font name reads as still changes the font
CodeRabbit on #2249: a Calibri paragraph is shown as Helvetica, so picking Helvetica left
look.family equal to look0.family and no family change was sent. The pick is now tracked
(LineEditor::choose_family) and counts as a change in style(), font_shown(), has_changes()
and the editor preview; Keep source fonts and an applied edit clear it.
Co-Authored-By: Claude Sonnet 5.5
* Format text: the reusable text style follows a selection only when its edit was applied
A refused UpdateContent used to leave the new style in app.text_style, so the next
added text inherited formatting the selected text never received.
Co-Authored-By: Claude Sonnet 5.5
Claude-Session: https://claude.ai/code/session_01Jk4iYSh3mEd145niec77LF
---------
Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
---
ATTRIBUTION.md | 6 +-
ATTRIBUTION.toml | 88 +++++--
assets/icons/move-horizontal.svg | 17 ++
assets/icons/pilcrow.svg | 17 ++
assets/icons/subscript.svg | 17 ++
assets/icons/superscript.svg | 17 ++
crates/ui-egui/src/content_ui.rs | 47 +---
crates/ui-egui/src/edit_text_ui.rs | 94 +++++---
crates/ui-egui/src/format_text_ui.rs | 333 +++++++++++++++++++++++++++
crates/ui-egui/src/i18n/ar.tsv | 6 +
crates/ui-egui/src/i18n/bg.tsv | 7 +
crates/ui-egui/src/i18n/cs.tsv | 7 +
crates/ui-egui/src/i18n/de.tsv | 7 +
crates/ui-egui/src/i18n/el.tsv | 7 +
crates/ui-egui/src/i18n/es.tsv | 7 +
crates/ui-egui/src/i18n/fr.tsv | 7 +
crates/ui-egui/src/i18n/hu.tsv | 7 +
crates/ui-egui/src/i18n/it.tsv | 7 +
crates/ui-egui/src/i18n/ja.tsv | 7 +
crates/ui-egui/src/i18n/kk.tsv | 7 +
crates/ui-egui/src/i18n/lt.tsv | 6 +
crates/ui-egui/src/i18n/pl.tsv | 7 +
crates/ui-egui/src/i18n/pt-br.tsv | 7 +
crates/ui-egui/src/i18n/ru.tsv | 7 +
crates/ui-egui/src/i18n/sl.tsv | 7 +
crates/ui-egui/src/i18n/te.tsv | 7 +
crates/ui-egui/src/i18n/tr.tsv | 7 +
crates/ui-egui/src/i18n/uk.tsv | 7 +
crates/ui-egui/src/i18n/vi.tsv | 6 +
crates/ui-egui/src/i18n/zh-hans.tsv | 7 +
crates/ui-egui/src/i18n/zh-hant.tsv | 7 +
crates/ui-egui/src/icon_data.rs | 4 +
crates/ui-egui/src/icons.rs | 2 +-
crates/ui-egui/src/lib.rs | 1 +
crates/ui-egui/src/panels.rs | 97 ++++----
crates/ui-egui/tests/content.rs | 59 ++++-
crates/ui-egui/tests/editing.rs | 60 ++++-
parity/acrobat-features.toml | 8 +-
38 files changed, 863 insertions(+), 155 deletions(-)
create mode 100644 assets/icons/move-horizontal.svg
create mode 100644 assets/icons/pilcrow.svg
create mode 100644 assets/icons/subscript.svg
create mode 100644 assets/icons/superscript.svg
create mode 100644 crates/ui-egui/src/format_text_ui.rs
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 5cfee8d55a..75ebd1d7ca 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 (272)
+## In this repository (276)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -280,6 +280,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene 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 | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
+| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index fbabdf6d44..514b5c2f06 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 = "e2ea97b4c68e13c22ef7f6f39f9a29d21af0e562005e2fdc9e7dfdf29effe0bb"
+sha256 = "467f1e5b55c0f036b35bb72b8a23c7cd0a8bd96ae188d742393b7dc0635166df"
# 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 = "ac0a78f36b91c005b9543e6028b78bb44949fea1bf7991bf0791d407696cd832"
+sha256 = "4a9d4428c3f67e256aa2f9e9d2d057c0e40f60a4446a0b384f3055894a9a9213"
[[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 = "c52b9a2aa403937e70859fe60b51719c97475c24927322d7534f5da0bdb5f420"
+sha256 = "f22298173d1e174feb83f6fb20e7079bb9091df86f396c69648b974f429be158"
[[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 = "0dcad418fa6399955adf2a8398aec307c2c73b13a37ca78ffe142e00491ef586"
+sha256 = "e320d5f50795dc1c46d589d72c7a27facb6164938cf9d75151e904b7450b142d"
[[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 = "130f0256d4ba09bc9cb6a87fc973bad96d6b155703b713341f0afead9ad6b4b3"
+sha256 = "0dd7e0e0b0843b519cebc5504ce7006e4c9cb1eef02b270ed52d008972ee7ff3"
[[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 = "d1093647d2af3d51f77a7b53f05ef1f45442128d40e696022624fbbece5bd0f0"
+sha256 = "dda5b3aea60d718eb2fe28e02a76728a3c895487e03f9319c2bcdef7ea2012db"
[[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 = "135d00bc60ef62f45ebee7c7fd83bc7433fd4054a04fb3901b2d2e8bdd2871c7"
+sha256 = "3351f6abd5e2796ad5e70b00604c9a7093387243ad1951713a717d06bffe419d"
[[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 = "0cb38f42fe45831495699f81837fb0b2d5bb16b1b69153359c4376e9bc1e6e04"
+sha256 = "2595a893a3cb84b6f0344b9e2144d9e956e80f778eb7d045fa2ad503e3cfb267"
[[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 = "ce97821a0fd6bf758e88e654f70985ed1e94c81e36f4e31ee3fc2f05ca39d99f"
+sha256 = "ec2d2afca09390c809e805a52108f5e8c94bf61feef7d0ba2901723db747323d"
[[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 = "45567329bdb7884b0b54e5c5161e6e73452d85200945f1ba3661eab1fa35da06"
+sha256 = "683f515a21dbc4b22b312c63e39b4d2a572a1f1cf733f02127f1ee62dbb7a05e"
[[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 = "421ef329305624ebfc661a8d9cd82131e79ec279b8be47d4baec29a226e494d9"
+sha256 = "e31d3969393994c33b90cd5fa82e80584955e4e0676df37136e345db476623ed"
[[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 = "97ed35e8959777a5f15b087136e53385430241e8522c7dc72a383c72dc0a27fd"
+sha256 = "fee17b5090aba63d3129772c271d9802444918fe3a905734b41e006e320e9234"
[[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 = "bc012e0dd9ad0d212a32b71bfc75a42ffcd058484aab28d173078384c9da480f"
+sha256 = "ef26690aeec1c4b6b648ac50bc2433fec681524ab98e6270cabd3d76be6d3e14"
[[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 = "bd611d470f40a01b4442119f7189dabbe58b4904dbce398115399f02b2ecddaa"
+sha256 = "2452a0f56f455ad71cae439a2ecf6da0ae5e2aaa90790de4d16e222393030283"
[[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 = "1132e904bc1a2bcc8239987aa74b2fa75ca17d8eb20b5458eec7e2c075c0445a"
+sha256 = "bbf8c454edc80ddeaecce63e56f5bceba335ae6cce8e4456b14742cd3a820bfe"
[[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 = "11dd8250c667ca49c40b827632b2d83cad93d07a9d5a5f27b5b9d657293efb6b"
+sha256 = "64d3c1c532ecb94b44b2c894acdaf171a130cbaa8e173b099f9b9b249d2134f4"
[[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 = "6aa0788d784532fb67a0b589cecc27f727e6178221ee892c3de7fe8f75790607"
+sha256 = "9e87a8f108b49213c7bb0f077a2e2f1bae449e1459a908d8202beba280d8055c"
[[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 = "ed84a08b83035bc6f4d328b916692d40140c2748d383741d04fba6e013a76bb3"
+sha256 = "921e8e906bdc42675fe8922fc00835a255e019ed61688a01095367f6e881f187"
[[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 = "1a5b3e9ad3f524e04bdb60e11f4a1424ca749a5ed4840e22abeaa97d24d235e8"
+sha256 = "59070be5688d7d183a7354f08f9604e8f962dcc5e223dd91d1cd940997c47399"
[[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 = "136f1e68fc4caa5e6e8fe395551bffdc9ace93bb00e2da5ba59318846494420d"
+sha256 = "f48accb38d4c824cb3d2f71fcffe0d8edb0a9a5f7412b9c479da6fb2479e45d2"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "052ecb803601ebc547934136c936fb5527d6dc97e49cf8cfbde0aa2b1a6cb8c7"
+sha256 = "42212a46ddca212bb94c15062be3b444b33181ea68cdd9880fc5191f56d986a5"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "299c274d071478c49430d8dbc2a1979085fa4f8735324697e8ac0db3d3adce7c"
+sha256 = "fcc38245d923c4ddefb5560fe7fdfe5bc6c1b9c49d4fcb25f2873e63dff4f806"
[[asset]]
@@ -3376,3 +3376,47 @@ licence_file = "vendor/vello_common/LICENSE-MIT"
kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
+
+[[asset]]
+path = "assets/icons/superscript.svg"
+title = "Lucide icon \"superscript\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0"
+
+[[asset]]
+path = "assets/icons/subscript.svg"
+title = "Lucide icon \"subscript\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4"
+
+[[asset]]
+path = "assets/icons/pilcrow.svg"
+title = "Lucide icon \"pilcrow\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+
+[[asset]]
+path = "assets/icons/move-horizontal.svg"
+title = "Lucide icon \"move-horizontal\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg
new file mode 100644
index 0000000000..7be9e3af6b
--- /dev/null
+++ b/assets/icons/move-horizontal.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg
new file mode 100644
index 0000000000..1a1b38b15b
--- /dev/null
+++ b/assets/icons/pilcrow.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg
new file mode 100644
index 0000000000..a37e9c42d1
--- /dev/null
+++ b/assets/icons/subscript.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg
new file mode 100644
index 0000000000..c7978fb095
--- /dev/null
+++ b/assets/icons/superscript.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index 5f5ef32f77..87f4c7756b 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -4,7 +4,7 @@
//! Delete to remove it. Each change is one undoable engine edit.
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
-use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign};
+use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
@@ -605,51 +605,6 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option {
}
}
-/// Format text (shown in the Edit panel while a text item is selected, or for new text).
-/// Returns the changed style; for a selected item the caller turns it into an update.
-pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option {
- let mut s = style.clone();
- widgets::section_title(ui, tl!("Format text"));
- ui.horizontal(|ui| {
- egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| {
- for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {
- ui.selectable_value(&mut s.family, f, f.label());
- }
- });
- // A list rather than a drag value: every change is an undo step.
- egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| {
- for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] {
- ui.selectable_value(&mut s.size, size, format!("{size} pt"));
- }
- });
- });
- ui.horizontal(|ui| {
- if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() {
- s.bold = !s.bold;
- }
- if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() {
- s.italic = !s.italic;
- }
- ui.separator();
- for (a, icon, tip) in [
- (TextAlign::Left, "align-left", tl!("Align left")),
- (TextAlign::Center, "align-center", tl!("Centre")),
- (TextAlign::Right, "align-right", tl!("Align right")),
- (TextAlign::Justify, "align-justify", tl!("Justify")),
- ] {
- if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() {
- s.align = a;
- }
- }
- });
- let c = s.color;
- if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) {
- s.color = picked;
- }
- ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint));
- (s != *style).then_some(s)
-}
-
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 66ad98ffe6..d272480d2a 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -162,6 +162,10 @@ pub struct LineEditor {
/// The Format text panel's values, and what the paragraph had (to send only changes).
pub look: pdfcraft_engine::AddedText,
look0: pdfcraft_engine::AddedText,
+ /// A standard family was picked from the font list. The family guessed from a PDF font name
+ /// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone
+ /// would miss the choice.
+ family_chosen: bool,
/// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and
/// horizontal scale (%), and what they were.
pub extras: Extras,
@@ -182,36 +186,42 @@ impl Default for Extras {
}
}
-/// Underline, line spacing, character spacing and horizontal scale for the paragraph being
-/// edited (under Format text). Returns `true` when something changed.
-pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool {
- let before = *e;
- ui.horizontal(|ui| {
- if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() {
- e.underline = !e.underline;
- }
- let zero = tl!("Line spacing").to_string();
- let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") };
- egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| {
- for v in [1.0, 1.15, 1.5, 2.0] {
- ui.selectable_value(&mut e.line_spacing, v, label(v));
- }
- });
- });
- ui.horizontal(|ui| {
- let l = ui.label(tl!("Character spacing"));
- ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id);
- });
- ui.horizontal(|ui| {
- let l = ui.label(tl!("Horizontal scale"));
- ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id);
- });
- *e != before
-}
-
impl LineEditor {
+ /// What Format text's font list shows: the chosen page font, a standard family picked
+ /// instead, or the paragraph's own font (its `/BaseFont` without the subset tag).
+ pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String {
+ if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) {
+ return face.clone();
+ }
+ if self.look.family != self.look0.family || self.family_chosen {
+ return self.look.family.label().to_string();
+ }
+ let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default();
+ // "ABCDEF+Calibri" is a subset of Calibri.
+ let name = match base.split_once('+') {
+ Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest,
+ _ => base,
+ };
+ if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() }
+ }
+
+ /// Back to the paragraph's own font: no page font chosen and the family it started with.
+ pub(crate) fn keep_source_font(&mut self) {
+ self.font_resource = None;
+ self.look.family = self.look0.family;
+ self.family_chosen = false;
+ }
+
+ /// A standard family picked from the font list, replacing the paragraph's font even when it
+ /// is the family the paragraph's font name reads as.
+ pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) {
+ self.font_resource = None;
+ self.look.family = family;
+ self.family_chosen = true;
+ }
+
pub(crate) fn has_changes(&self) -> bool {
- self.text != self.original || self.look != self.look0 || self.extras != self.extras0
+ self.text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen
}
/// Extra width permitted by the editor. Source containers now remain fixed.
@@ -229,7 +239,7 @@ impl LineEditor {
let (l, o) = (&self.look, &self.look0);
let mut style = pdfcraft_engine::BlockStyle {
resource: self.font_resource.clone(),
- family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
+ family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
size: (l.size != o.size).then_some(l.size),
color: (l.color != o.color).then_some(l.color),
align: (l.align != o.align).then_some(l.align),
@@ -267,6 +277,7 @@ impl LineEditor {
pub fn applied(&mut self) {
self.selection = None;
self.font_resource = None;
+ self.family_chosen = false;
self.look0 = self.look.clone();
self.extras0 = self.extras;
self.original = self.text.clone();
@@ -965,6 +976,7 @@ fn open_editor(
focus: true,
look: look_of(l),
look0: look_of(l),
+ family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
});
@@ -1177,7 +1189,7 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32
let Some(line) = lines.get(index) else { continue };
let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic);
look.size = line.size;
- if ed.look.family != ed.look0.family {
+ if ed.look.family != ed.look0.family || ed.family_chosen {
look.family = ed.look.family;
}
if ed.look.bold != ed.look0.bold {
@@ -1193,7 +1205,8 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32
look.size = ed.look.size;
}
let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0));
- let original_face = ed.look.family == ed.look0.family && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic;
+ let original_face =
+ ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic;
let embedded = original_face && ed.fonts.contains_key(&line.font);
if embedded && let Some(name) = ed.fonts.get(&line.font) {
font.family = FontFamily::Name(name.clone().into());
@@ -1474,12 +1487,31 @@ mod tests {
focus: true,
look: look_of(&block),
look0: look_of(&block),
+ family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
};
(app, ed)
}
+ #[test]
+ fn picking_the_family_the_font_name_reads_as_still_changes_the_font() {
+ // A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave
+ // `look` equal to what the paragraph had, so no family change was sent and Calibri stayed.
+ let (_app, mut ed) = preview_app();
+ let family = ed.look0.family;
+ assert_eq!(ed.style().family, None, "nothing chosen yet");
+ ed.choose_family(family);
+ assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic)));
+ assert!(ed.has_changes());
+ assert_eq!(ed.font_shown(&[]), family.label());
+ ed.keep_source_font();
+ assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice");
+ ed.choose_family(family);
+ ed.applied();
+ assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now");
+ }
+
#[test]
fn a_loaded_font_is_applied_to_the_paragraph_being_edited() {
// Loading a font used to only register it on the page: the paragraph kept its source
diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs
new file mode 100644
index 0000000000..798b844a9e
--- /dev/null
+++ b/crates/ui-egui/src/format_text_ui.rs
@@ -0,0 +1,333 @@
+//! Format text, laid out as Acrobat's panel: the font across the panel; size, colour, lists and
+//! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing;
+//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the
+//! fonts already on its page and the spacing controls) and added text (the standard families,
+//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and
+//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so.
+//!
+//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
+
+use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
+use pdfcraft_engine::{AddedText, FontFamily, TextAlign};
+
+use crate::edit_text_ui::Extras;
+use crate::theme::Tokens;
+use crate::{icons, widgets};
+
+/// A font picked from the font list. The caller applies it, since keeping the paragraph's own
+/// font also means restoring the family it started with.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum FontChoice {
+ /// The fonts the paragraph already uses.
+ KeepSource,
+ /// One of the page's fonts, by resource name.
+ Resource(String),
+ /// A standard family.
+ Family(FontFamily),
+ /// Load font…: pick a font file.
+ Load,
+ /// A font file installed on this computer.
+ Installed(std::path::PathBuf),
+}
+
+/// What the font list offers and shows.
+pub(crate) struct Fonts<'a> {
+ /// The fonts on the paragraph's page (resource, face); `None` for added text, which has the
+ /// standard families only.
+ pub sources: Option<&'a [(String, String)]>,
+ /// The name the list shows for the current choice.
+ pub shown: String,
+}
+
+/// What the panel changed: the style (in place) and a font picked from the list.
+#[derive(Default)]
+pub(crate) struct Outcome {
+ pub changed: bool,
+ pub font: Option,
+}
+
+const SIZES: [f64; 14] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0];
+const LINE_SPACINGS: [f64; 7] = [1.0, 1.15, 1.2, 1.5, 2.0, 2.5, 3.0];
+const SCALES: [f64; 8] = [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0];
+const CHAR_SPACINGS: [f64; 8] = [-1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 3.0, 5.0];
+/// A row's height: the tallest control in it (the 30 pt icon buttons). Every control is centred
+/// on the row, whatever its own height, so lists, swatches and buttons line up (#3037).
+const ROW: f32 = 30.0;
+/// The gap between a row's controls.
+const GAP: f32 = 6.0;
+/// The icon naming a list or field ([`labelled_icon`]).
+const LABEL_ICON: f32 = 22.0;
+/// The button beside a number field that opens its list of common values: just an arrow, so the
+/// field keeps the room its value needs.
+const PRESET_LIST: f32 = 22.0;
+/// What can be typed: horizontal scale in percent, character spacing in points.
+const SCALE_RANGE: std::ops::RangeInclusive = 10.0..=400.0;
+const CHAR_SPACING_RANGE: std::ops::RangeInclusive = -5.0..=50.0;
+
+/// A number as the lists show it: no trailing zeros ("1.2", "100", "-0.5").
+fn number(v: f64) -> String {
+ let s = format!("{v:.2}");
+ s.trim_end_matches('0').trim_end_matches('.').to_string()
+}
+
+pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome {
+ let before = (look.clone(), extras.as_deref().copied());
+ let mut out = Outcome::default();
+ widgets::section_title(ui, tl!("Format text"));
+
+ // The font, across the panel.
+ let width = ui.available_width();
+ egui::ComboBox::from_id_salt("format-font")
+ .selected_text(fonts.shown.as_str())
+ .width(width)
+ .show_ui(ui, |ui| {
+ if let Some(sources) = fonts.sources {
+ if ui.selectable_label(false, tl!("Keep source fonts")).clicked() {
+ out.font = Some(FontChoice::KeepSource);
+ }
+ for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) {
+ if ui.selectable_label(false, face).clicked() {
+ out.font = Some(FontChoice::Resource(resource.clone()));
+ }
+ }
+ ui.separator();
+ }
+ for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {
+ if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() {
+ out.font = Some(FontChoice::Family(f));
+ }
+ }
+ if fonts.sources.is_some() {
+ ui.separator();
+ if ui.selectable_label(false, tl!("Load font…")).clicked() {
+ out.font = Some(FontChoice::Load);
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) {
+ Some(list) if list.is_empty() => {
+ ui.label(tl!("No openly licensed document fonts found. Load a font file."));
+ }
+ Some(list) => {
+ for font in list {
+ if ui.button(&font.face).clicked() {
+ out.font = Some(FontChoice::Installed(font.path));
+ ui.close();
+ }
+ }
+ }
+ None => {
+ ui.label(tl!("Finding document fonts…"));
+ }
+ });
+ }
+ })
+ .response
+ .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available.")));
+ ui.add_space(4.0);
+
+ // Size and colour | bullets, numbering and alignment.
+ row(ui, |ui| {
+ let _ = egui::ComboBox::from_id_salt("format-size")
+ .selected_text(number(look.size))
+ .width(64.0)
+ .show_ui(ui, |ui| {
+ for size in SIZES {
+ ui.selectable_value(&mut look.size, size, number(size));
+ }
+ })
+ .response
+ .on_hover_text(tl!("Font size"));
+ let swatch = colour_button(ui, t, look.color);
+ egui::Popup::menu(&swatch).show(|ui| {
+ if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) {
+ look.color = picked;
+ ui.close();
+ }
+ });
+ let (sep, _) = ui.allocate_exact_size(vec2(9.0, 26.0), Sense::hover());
+ ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider));
+ planned(ui, "list", tl!("Bulleted list"));
+ planned(ui, "list-ordered", tl!("Numbered list"));
+ let current = match look.align {
+ TextAlign::Left => "align-left",
+ TextAlign::Center => "align-center",
+ TextAlign::Right => "align-right",
+ TextAlign::Justify => "align-justify",
+ };
+ let align = icons::button(ui, current, 30.0, false, tl!("Alignment"));
+ let align = flyout(ui, t, align);
+ egui::Popup::menu(&align).show(|ui| {
+ ui.horizontal(|ui| {
+ for (a, icon, tip) in [
+ (TextAlign::Left, "align-left", tl!("Align left")),
+ (TextAlign::Center, "align-center", tl!("Centre")),
+ (TextAlign::Right, "align-right", tl!("Align right")),
+ (TextAlign::Justify, "align-justify", tl!("Justify")),
+ ] {
+ if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() {
+ look.align = a;
+ ui.close();
+ }
+ }
+ });
+ });
+ });
+
+ // Bold, italic, underline, superscript, subscript.
+ let mut extras = extras;
+ row(ui, |ui| {
+ if icons::button(ui, "bold", 30.0, look.bold, tl!("Bold")).clicked() {
+ look.bold = !look.bold;
+ }
+ if icons::button(ui, "italic", 30.0, look.italic, tl!("Italic")).clicked() {
+ look.italic = !look.italic;
+ }
+ match extras.as_deref_mut() {
+ Some(e) => {
+ if icons::button(ui, "underline", 30.0, e.underline, tl!("Underline")).clicked() {
+ e.underline = !e.underline;
+ }
+ }
+ None => planned(ui, "underline", tl!("Underline")),
+ }
+ planned(ui, "superscript", tl!("Superscript"));
+ planned(ui, "subscript", tl!("Subscript"));
+ });
+
+ // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an
+ // icon and its control, `half` wide.
+ let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0);
+ ui.add_enabled_ui(extras.is_some(), |ui| {
+ let mut fallback = Extras::default();
+ let e = extras.as_deref_mut().unwrap_or(&mut fallback);
+ row(ui, |ui| {
+ labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing"));
+ let own = tl!("Auto").to_string();
+ let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) };
+ egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| {
+ ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str());
+ for v in LINE_SPACINGS {
+ ui.selectable_value(&mut e.line_spacing, v, number(v));
+ }
+ });
+ ui.add_space(8.0);
+ ui.add_enabled_ui(false, |ui| {
+ labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing"));
+ let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned"));
+ let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing")
+ .selected_text("0")
+ .width(half)
+ .show_ui(ui, |_| {})
+ .response
+ .on_disabled_hover_text(tip);
+ });
+ });
+ // Any value the engine takes can be typed; the lists hold the common ones.
+ row(ui, |ui| {
+ let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale"));
+ let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" };
+ number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale")));
+ ui.add_space(8.0);
+ let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing"));
+ let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" };
+ number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
+ });
+ });
+
+ out.changed = (look.clone(), extras.as_deref().copied()) != before;
+ ui.add_space(6.0);
+ ui.separator();
+ out
+}
+
+/// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it.
+fn row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
+ let size = vec2(ui.available_width(), ROW);
+ ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| {
+ ui.spacing_mut().item_spacing.x = GAP;
+ // A list lays itself out from the default control height and grows downward, which leaves
+ // it below the row's middle; at a list's own height it is centred like the rest.
+ let list = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y;
+ ui.spacing_mut().interact_size.y = ui.spacing().interact_size.y.max(list);
+ add(ui)
+ })
+ .inner
+}
+
+/// A control PdfCraft doesn't have yet: its icon, disabled, saying so when hovered.
+fn planned(ui: &mut egui::Ui, icon: &str, label: &str) {
+ let _ = ui
+ .add_enabled_ui(false, |ui| icons::button(ui, icon, 30.0, false, label))
+ .inner
+ .on_disabled_hover_text(format!("{label} — {}", tl!("Planned")));
+}
+
+/// The small corner mark Acrobat puts on a button that opens a menu.
+fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response {
+ let r = resp.rect;
+ let tri = vec![r.right_bottom() + vec2(-3.0, -3.0), r.right_bottom() + vec2(-8.0, -3.0), r.right_bottom() + vec2(-3.0, -8.0)];
+ ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE));
+ resp
+}
+
+/// An icon naming the list beside it, for screen readers too. Returns it, for the control it
+/// labels.
+fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response {
+ let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label));
+ let tint = if ui.is_enabled() { t.icon } else { t.text_faint };
+ icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint);
+ resp.on_hover_text(label)
+}
+
+/// A value typed in a number field: what it edits, what may be typed, and how it reads.
+struct Number<'a> {
+ value: &'a mut f64,
+ range: std::ops::RangeInclusive,
+ speed: f64,
+ suffix: &'static str,
+}
+
+/// A number field with an arrow beside it that lists common values, in `width` all told: type any
+/// value in its range, or pick one. Both are named by `label`: the icon before them and its text.
+fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) {
+ ui.scope(|ui| {
+ ui.spacing_mut().item_spacing.x = 2.0;
+ let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix);
+ let height = ui.spacing().interact_size.y;
+ ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0);
+ let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1);
+ egui::Popup::menu(&list).show(|ui| {
+ for &v in presets {
+ if ui.selectable_label(*n.value == v, number(v)).clicked() {
+ *n.value = v;
+ ui.close();
+ }
+ }
+ });
+ });
+}
+
+/// The text colour as a square swatch that opens the palette.
+fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response {
+ let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour")));
+ let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
+ let fill = rect.shrink(4.0);
+ ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b));
+ let stroke = if resp.hovered() { t.text } else { t.border };
+ ui.painter().rect_stroke(fill, CornerRadius::same(2), Stroke::new(1.0, stroke), egui::StrokeKind::Outside);
+ crate::theme::hand(resp).on_hover_text(tl!("Colour"))
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn numbers_read_like_the_lists() {
+ assert_eq!(super::number(1.2), "1.2");
+ assert_eq!(super::number(100.0), "100");
+ assert_eq!(super::number(-0.5), "-0.5");
+ assert_eq!(super::number(0.0), "0");
+ assert_eq!(super::number(1.15), "1.15");
+ }
+}
diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv
index 4bb58ba8e1..68413b493d 100644
--- a/crates/ui-egui/src/i18n/ar.tsv
+++ b/crates/ui-egui/src/i18n/ar.tsv
@@ -2711,5 +2711,11 @@ certificate Version الإصدار
Make properties default جعل الخصائص افتراضية
Pages selected الصفحات المحددة
Page properties خصائص الصفحة
+ Bulleted list قائمة نقطية
+ Numbered list قائمة مرقمة
+ Alignment المحاذاة
+ Superscript نص مرتفع
+ Subscript نص منخفض
+ Paragraph spacing تباعد الفقرات
Less أقل
Other options خيارات أخرى
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 584fafda3f..dabbc53d7d 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2397,5 +2397,12 @@ animation effects Off Изключени
Make properties default Задаване като свойства по подразбиране
Pages selected Избрани страници
Page properties Свойства на страницата
+ Font Шрифт
+ Bulleted list Списък с водещи символи
+ Numbered list Номериран списък
+ Alignment Подравняване
+ Superscript Горен индекс
+ Subscript Долен индекс
+ Paragraph spacing Разстояние между абзаците
Less По-малко
Other options Други опции
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index d347ca95d7..3d6d65bafb 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -2508,5 +2508,12 @@ animation effects Off Vypnuto
Make properties default Nastavit vlastnosti jako výchozí
Pages selected Vybrané stránky
Page properties Vlastnosti stránky
+ Font Písmo
+ Bulleted list Odrážkový seznam
+ Numbered list Číslovaný seznam
+ Alignment Zarovnání
+ Superscript Horní index
+ Subscript Dolní index
+ Paragraph spacing Mezera mezi odstavci
Less Méně
Other options Další možnosti
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index cb1ffc2fd9..916a03b94b 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2435,5 +2435,12 @@ animation effects Off Aus
Make properties default Eigenschaften als Standard festlegen
Pages selected Ausgewählte Seiten
Page properties Seiteneigenschaften
+ Font Schrift
+ Bulleted list Aufzählung
+ Numbered list Nummerierte Liste
+ Alignment Ausrichtung
+ Superscript Hochgestellt
+ Subscript Tiefgestellt
+ Paragraph spacing Absatzabstand
Less Weniger
Other options Weitere Optionen
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 2bde0b50b9..6ace4f9023 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -2440,5 +2440,12 @@ animation effects Off Ανενεργά
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
Pages selected Επιλεγμένες σελίδες
Page properties Ιδιότητες σελίδας
+ Font Γραμματοσειρά
+ Bulleted list Λίστα με κουκκίδες
+ Numbered list Αριθμημένη λίστα
+ Alignment Στοίχιση
+ Superscript Εκθέτης
+ Subscript Δείκτης
+ Paragraph spacing Απόσταση παραγράφων
Less Λιγότερα
Other options Άλλες επιλογές
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index fbc9f54c98..07c1bfd6c0 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2570,6 +2570,13 @@ animation effects Off Desactivados
Make properties default Usar como propiedades predeterminadas
Pages selected Páginas seleccionadas
Page properties Propiedades de página
+ Font Fuente
+ Bulleted list Lista con viñetas
+ Numbered list Lista numerada
+ Alignment Alineación
+ Superscript Superíndice
+ Subscript Subíndice
+ Paragraph spacing Espaciado entre párrafos
Less Menos
Other options Otras opciones
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index bde247a8d8..59a2c74f1b 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2434,5 +2434,12 @@ animation effects Off Désactivés
Make properties default Définir comme propriétés par défaut
Pages selected Pages sélectionnées
Page properties Propriétés de la page
+ Font Police
+ Bulleted list Liste à puces
+ Numbered list Liste numérotée
+ Alignment Alignement
+ Superscript Exposant
+ Subscript Indice
+ Paragraph spacing Espacement des paragraphes
Less Moins
Other options Autres options
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 16efc30a0d..6fb896ea32 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2433,5 +2433,12 @@ animation effects Off Ki
Make properties default Tulajdonságok beállítása alapértelmezettként
Pages selected Kijelölt oldalak
Page properties Oldaltulajdonságok
+ Font Betűtípus
+ Bulleted list Felsorolás
+ Numbered list Számozott lista
+ Alignment Igazítás
+ Superscript Felső index
+ Subscript Alsó index
+ Paragraph spacing Bekezdéstávolság
Less Kevesebb
Other options Egyéb beállítások
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index c46f9cf81b..e3390dfb12 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2646,5 +2646,12 @@ certificate Key usage Utilizzo chiave
certificate Self-signed Autofirmato
certificate SHA-1 digest Impronta SHA-1
certificate SHA-256 digest Impronta SHA-256
+ Font Carattere
+ Bulleted list Elenco puntato
+ Numbered list Elenco numerato
+ Alignment Allineamento
+ Superscript Apice
+ Subscript Pedice
+ Paragraph spacing Spaziatura paragrafi
Less Meno
Other options Altre opzioni
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 54bc546338..84b45a9cd5 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2434,5 +2434,12 @@ animation effects Off オフ
Make properties default プロパティをデフォルトに設定
Pages selected 選択したページ
Page properties ページのプロパティ
+ Font フォント
+ Bulleted list 箇条書き
+ Numbered list 番号付きリスト
+ Alignment 配置
+ Superscript 上付き
+ Subscript 下付き
+ Paragraph spacing 段落間隔
Less 折りたたむ
Other options その他のオプション
diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv
index 5ca11b941d..4306b5055b 100644
--- a/crates/ui-egui/src/i18n/kk.tsv
+++ b/crates/ui-egui/src/i18n/kk.tsv
@@ -2432,5 +2432,12 @@ animation effects Off Өшірулі
Make properties default Қасиеттерді әдепкі ету
Pages selected Таңдалған беттер
Page properties Бет қасиеттері
+ Font Қаріп
+ Bulleted list Таңбалауыш тізім
+ Numbered list Нөмірленген тізім
+ Alignment Туралау
+ Superscript Жоғарғы индекс
+ Subscript Төменгі индекс
+ Paragraph spacing Абзац аралығы
Less Азырақ
Other options Басқа параметрлер
diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv
index 2674dfc86b..5a5306ba07 100644
--- a/crates/ui-egui/src/i18n/lt.tsv
+++ b/crates/ui-egui/src/i18n/lt.tsv
@@ -2685,5 +2685,11 @@ key Space Tarpas
The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai.
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti.
theme Appearance Išvaizda
+ Bulleted list Sąrašas su ženkleliais
+ Numbered list Numeruotas sąrašas
+ Alignment Lygiuotė
+ Superscript Viršutinis indeksas
+ Subscript Apatinis indeksas
+ Paragraph spacing Tarpas tarp pastraipų
Less Mažiau
Other options Kitos parinktys
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index d6d5e5e93b..2d423c2a29 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -2410,5 +2410,12 @@ animation effects Off Wyłączone
Make properties default Ustaw właściwości jako domyślne
Pages selected Zaznaczone strony
Page properties Właściwości strony
+ Font Czcionka
+ Bulleted list Lista punktowana
+ Numbered list Lista numerowana
+ Alignment Wyrównanie
+ Superscript Indeks górny
+ Subscript Indeks dolny
+ Paragraph spacing Odstęp między akapitami
Less Mniej
Other options Inne opcje
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index ee041f4af1..2729e2b971 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2448,5 +2448,12 @@ animation effects Off Desativados
Make properties default Tornar propriedades padrão
Pages selected Páginas selecionadas
Page properties Propriedades da página
+ Font Fonte
+ Bulleted list Lista com marcadores
+ Numbered list Lista numerada
+ Alignment Alinhamento
+ Superscript Sobrescrito
+ Subscript Subscrito
+ Paragraph spacing Espaçamento entre parágrafos
Less Menos
Other options Outras opções
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 72fcb1ddcf..af7dc384b7 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2347,5 +2347,12 @@ animation effects Off Выкл
Make properties default Сделать свойства стандартными
Pages selected Выделенные страницы
Page properties Свойства страницы
+ Font Шрифт
+ Bulleted list Маркированный список
+ Numbered list Нумерованный список
+ Alignment Выравнивание
+ Superscript Надстрочный
+ Subscript Подстрочный
+ Paragraph spacing Интервал между абзацами
Less Меньше
Other options Другие параметры
diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv
index 009131307f..fd00ce09e7 100644
--- a/crates/ui-egui/src/i18n/sl.tsv
+++ b/crates/ui-egui/src/i18n/sl.tsv
@@ -2439,5 +2439,12 @@ animation effects Off Izklopljeni
Make properties default Nastavi lastnosti kot privzete
Pages selected Izbrane strani
Page properties Lastnosti strani
+ Font Pisava
+ Bulleted list Označen seznam
+ Numbered list Oštevilčen seznam
+ Alignment Poravnava
+ Superscript Nadpisano
+ Subscript Podpisano
+ Paragraph spacing Razmik med odstavki
Less Manj
Other options Druge možnosti
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 80008d1912..f7e8444969 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -2343,5 +2343,12 @@ animation effects Off ఆఫ్
Make properties default లక్షణాలను డిఫాల్ట్గా చేయి
Pages selected ఎంచుకున్న పేజీలు
Page properties పేజీ లక్షణాలు
+ Font ఫాంట్
+ Bulleted list బుల్లెట్ జాబితా
+ Numbered list సంఖ్యల జాబితా
+ Alignment అమరిక
+ Superscript సూపర్స్క్రిప్ట్
+ Subscript సబ్స్క్రిప్ట్
+ Paragraph spacing పేరా అంతరం
Less తక్కువ
Other options ఇతర ఎంపికలు
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index b185f0262e..925ea2c4be 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -2430,5 +2430,12 @@ animation effects Off Kapalı
Make properties default Özellikleri varsayılan yap
Pages selected Seçilen sayfalar
Page properties Sayfa özellikleri
+ Font Yazı tipi
+ Bulleted list Madde işaretli liste
+ Numbered list Numaralı liste
+ Alignment Hizalama
+ Superscript Üst simge
+ Subscript Alt simge
+ Paragraph spacing Paragraf aralığı
Less Daha az
Other options Diğer seçenekler
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index c25eba25b7..a64c31fe09 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2456,5 +2456,12 @@ animation effects Off Вимкнено
Make properties default Зробити властивості типовими
Pages selected Вибрані сторінки
Page properties Властивості сторінки
+ Font Шрифт
+ Bulleted list Маркований список
+ Numbered list Нумерований список
+ Alignment Вирівнювання
+ Superscript Надрядковий
+ Subscript Підрядковий
+ Paragraph spacing Інтервал між абзацами
Less Менше
Other options Інші параметри
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index dbf9eb2245..b2a99ef4d6 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2683,6 +2683,12 @@ key Space Space
Words Từ
“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này.
@id file.word_count Đếm từ…
+ Bulleted list Danh sách dấu đầu dòng
+ Numbered list Danh sách đánh số
+ Alignment Căn lề
+ Paragraph spacing Khoảng cách đoạn
+ Superscript Chỉ số trên
+ Subscript Chỉ số dưới
Less Ít hơn
Other options Tùy chọn khác
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index a71b3f30e9..80623decf9 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2398,5 +2398,12 @@ animation effects Off 关
Make properties default 将属性设为默认
Pages selected 选定的页面
Page properties 页面属性
+ Font 字体
+ Bulleted list 项目符号列表
+ Numbered list 编号列表
+ Alignment 对齐
+ Superscript 上标
+ Subscript 下标
+ Paragraph spacing 段落间距
Less 收起
Other options 其他选项
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 630bfbf34b..cd8a1051e1 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2384,5 +2384,12 @@ animation effects Off 關
Make properties default 將內容設為預設
Pages selected 選取的頁面
Page properties 頁面屬性
+ Font 字型
+ Bulleted list 項目符號清單
+ Numbered list 編號清單
+ Alignment 對齊
+ Superscript 上標
+ Subscript 下標
+ Paragraph spacing 段落間距
Less 收起
Other options 其他選項
diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs
index 6e3f26f57c..f986cd6bf4 100644
--- a/crates/ui-egui/src/icon_data.rs
+++ b/crates/ui-egui/src/icon_data.rs
@@ -185,6 +185,10 @@ pub static ICONS: &[(&str, &[u8])] = &[
("user-round", include_bytes!("../../../assets/icons/user-round.svg")),
("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")),
("wrench", include_bytes!("../../../assets/icons/wrench.svg")),
+ ("move-horizontal", include_bytes!("../../../assets/icons/move-horizontal.svg")),
+ ("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")),
+ ("subscript", include_bytes!("../../../assets/icons/subscript.svg")),
+ ("superscript", include_bytes!("../../../assets/icons/superscript.svg")),
("x", include_bytes!("../../../assets/icons/x.svg")),
("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")),
("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")),
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index bd084333bb..79254afa2f 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -99,7 +99,7 @@ mod tests {
#[test]
fn shared_catalog_preserves_existing_icons_and_fallback_uri() {
- assert_eq!(ICONS.len(), 186);
+ assert_eq!(ICONS.len(), 190);
assert!(exists("minimize-2"));
for (name, original) in ICONS {
let expected =
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 34e7f4b614..ba4fc21fca 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -87,6 +87,7 @@ mod files;
pub mod fill_sign;
mod fling;
pub mod folders_ui;
+mod format_text_ui;
pub mod forms_ui;
mod home;
mod icon_data;
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 8d6660b643..829ebad7e1 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -516,50 +516,42 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
if let Some((i, _)) = app.active_ids()
&& let Some(ed) = app.views[i].line_editor.clone()
{
+ use crate::format_text_ui::{FontChoice, Fonts};
let mut ed = ed;
- let mut changed = false;
- let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default();
- egui::ComboBox::from_id_salt("document-font-resource")
- .selected_text(
- ed.font_resource
- .as_ref()
- .and_then(|r| font_choices.iter().find(|f| &f.resource == r))
- .map_or(tl!("Keep source fonts"), |f| f.face.as_str()),
- )
- .show_ui(ui, |ui| {
- changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed();
- for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) {
- changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed();
- }
- });
- if ui.button(tl!("Load font…")).clicked() {
- app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false);
- }
- #[cfg(not(target_arch = "wasm32"))]
- egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| {
- if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) {
- if fonts.is_empty() {
- ui.label(tl!("No openly licensed document fonts found. Load a font file."));
- }
- for font in fonts {
- if ui.selectable_label(false, &font.face).clicked() {
- app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]);
- }
- }
- } else {
- ui.label(tl!("Finding document fonts…"));
+ let sources: Vec<(String, String)> = app
+ .session
+ .get(app.views[i].id)
+ .map(|d| d.text_editor_fonts(ed.page))
+ .unwrap_or_default()
+ .into_iter()
+ .map(|f| (f.resource, f.face))
+ .collect();
+ let shown = ed.font_shown(&sources);
+ let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown });
+ let mut changed = out.changed;
+ match out.font {
+ Some(FontChoice::KeepSource) => {
+ ed.keep_source_font();
+ changed = true;
}
- });
- if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) {
- ed.look = look;
- changed = true;
+ Some(FontChoice::Resource(resource)) => {
+ ed.font_resource = Some(resource);
+ changed = true;
+ }
+ Some(FontChoice::Family(family)) => {
+ ed.choose_family(family);
+ changed = true;
+ }
+ Some(FontChoice::Load) => app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false),
+ #[cfg(not(target_arch = "wasm32"))]
+ Some(FontChoice::Installed(path)) => app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[path]),
+ #[cfg(target_arch = "wasm32")]
+ Some(FontChoice::Installed(_)) => {}
+ None => {}
}
- changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras);
if changed {
app.apply_line_format(i, ed, Some(ui.ctx()));
}
- ui.add_space(6.0);
- ui.separator();
return;
}
let image = app.active_ids().and_then(|(i, id)| {
@@ -593,21 +585,32 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
});
match selected {
Some((page, index, text)) => {
- if let Some(style) = crate::content_ui::format_panel(ui, t, &text) {
- app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style.clone() };
- app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style) });
+ let mut style = text.clone();
+ added_text_format(ui, t, &mut style);
+ if style != text {
+ // The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later.
+ if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) {
+ app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style };
+ }
}
}
None if app.quick_tool == crate::QuickTool::AddText => {
crate::content_ui::hint(ui, t);
- if let Some(style) = crate::content_ui::format_panel(ui, t, &app.text_style) {
- app.text_style = style;
- }
+ let mut style = app.text_style.clone();
+ added_text_format(ui, t, &mut style);
+ app.text_style = style;
}
- None => return,
+ None => {}
+ }
+}
+
+/// Format text for added text: the standard families, size, colour, style and alignment.
+fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) {
+ let shown = style.family.label().to_string();
+ let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown });
+ if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
+ style.family = family;
}
- ui.add_space(6.0);
- ui.separator();
}
impl PdfCraftApp {
diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs
index dd2c032eff..70e5a0dcb5 100644
--- a/crates/ui-egui/tests/content.rs
+++ b/crates/ui-egui/tests/content.rs
@@ -236,7 +236,7 @@ fn typing_moving_styling_and_deleting_added_content() {
h.run_steps(2);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected");
// Bold from the Format panel: one undoable change.
- h.get_by_label("B").click();
+ h.get_by_label("Bold").click();
h.run_steps(3);
let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() };
@@ -585,3 +585,60 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() {
let (old, new) = ink_at(&mut h, from, to);
assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)");
}
+
+/// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and
+/// the controls PdfCraft can't apply yet present but disabled.
+/// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and
+/// the alignment button and is as tall as the font list, each spacing row's controls share one
+/// line, and nothing reaches past the font list, which spans the panel.
+#[test]
+fn format_text_rows_line_up_and_fit_the_panel() {
+ use egui::accesskit::Role;
+ let mut h = harness();
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ h.get_by_label("FORMAT TEXT");
+ let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| {
+ h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value)
+ };
+ let (font, size) = (combo(&h, "Helvetica"), combo(&h, "12"));
+ let middle = |r: egui::Rect| r.center().y;
+ for label in ["Colour", "Alignment"] {
+ let other = h.get_by_label(label).rect();
+ assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})");
+ }
+ assert_eq!(size.height(), font.height(), "the font and size lists are the same height");
+ // Line and paragraph spacing; scale and character spacing with their lists.
+ let line = [combo(&h, "Auto"), combo(&h, "0")];
+ assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}");
+ // Each: its number field and the arrow that lists common values.
+ let scale_row: Vec =
+ ["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect();
+ assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}");
+ for r in &scale_row {
+ assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}");
+ }
+ for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row) {
+ assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})");
+ }
+}
+
+#[test]
+fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
+ use egui_kittest::kittest::NodeT;
+ let mut h = harness();
+ assert!(h.state_mut().execute("edit.text"));
+ h.run_steps(3);
+ h.get_by_label("FORMAT TEXT");
+ for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] {
+ assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled");
+ }
+ h.get_by_label("Alignment").click();
+ h.run_steps(2);
+ h.get_by_label("Centre").click();
+ h.run_steps(2);
+ assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center);
+ h.get_by_label("Bold").click();
+ h.run_steps(2);
+ assert!(h.state().text_style.bold);
+}
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index cced4fe03d..6885a2bb6d 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -3028,7 +3028,7 @@ fn the_format_panel_restyles_the_paragraph_being_edited() {
assert!(h.state().views[0].line_editor.is_some());
// The Format text panel shows the paragraph's look; making it bold applies at once.
h.get_by_label("FORMAT TEXT");
- h.get_by_label("B").click();
+ h.get_by_label("Bold").click();
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
@@ -3044,6 +3044,62 @@ fn the_format_panel_restyles_the_paragraph_being_edited() {
assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline));
}
+/// Horizontal scale and Character spacing take any value in their ranges, not only the listed
+/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved
+/// file as the engine writes them.
+#[test]
+fn the_format_panel_takes_any_scale_and_character_spacing() {
+ let mut h = harness(1, |_| {});
+ let id = h.state().views[0].id;
+ let width = |h: &Harness<'static, PdfCraftApp>, view: usize| {
+ let s = h.state();
+ let r = s.session.get(s.views[view].id).unwrap().text_blocks(0)[0].rect;
+ r[2] - r[0]
+ };
+ let plain = width(&h, 0);
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.run_steps(2);
+ let r = h.state().views[0].page_screen_rect(0).expect("on screen");
+ let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
+ h.hover_at(at);
+ h.run_steps(1);
+ h.drag_at(at);
+ h.run_steps(1);
+ h.drop_at(at);
+ h.run_steps(3);
+ assert!(h.state().views[0].line_editor.is_some());
+ // Typed the way a person does: click the field, type, Enter.
+ let type_in = |h: &mut Harness<'static, PdfCraftApp>, label: &str, value: &str| {
+ h.get_by_role_and_label(Role::SpinButton, label).click();
+ h.run_steps(2);
+ h.event(egui::Event::Text(value.into()));
+ h.run_steps(1);
+ h.key_press(Key::Enter);
+ h.run_steps(4);
+ };
+ let extras = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| (e.extras.scale, e.extras.char_spacing));
+ // Between the listed values (50…200 %, −1…5 pt) and beyond them, within what was always allowed.
+ type_in(&mut h, "Horizontal scale", "80");
+ assert_eq!(extras(&h), Some((80.0, 0.0)));
+ type_in(&mut h, "Horizontal scale", "250");
+ assert_eq!(extras(&h), Some((250.0, 0.0)));
+ type_in(&mut h, "Character spacing", "0.25");
+ assert_eq!(extras(&h), Some((250.0, 0.25)));
+ type_in(&mut h, "Character spacing", "-2");
+ assert_eq!(extras(&h), Some((250.0, -2.0)));
+ assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"));
+ // Saved and reopened, the paragraph is as wide as the engine draws it with those values.
+ let bytes = h.state().session.save_bytes(id).unwrap();
+ h.state_mut().open_bytes("reference.pdf", None, fixture(1)).expect("opens");
+ let style = pdfcraft_engine::BlockStyle { scale: Some(250.0), char_spacing: Some(-2.0), ..Default::default() };
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: "Page 1".into(), style }));
+ h.state_mut().open_bytes("saved.pdf", None, bytes.as_ref().clone()).expect("the saved file opens");
+ h.run_steps(3);
+ let (reference, saved) = (width(&h, 1), width(&h, 2));
+ assert!(saved > plain * 1.5, "250 % widens it: {plain} → {saved}");
+ assert!((saved - reference).abs() < 0.5, "saved {saved}, the engine's own {reference}");
+}
+
#[test]
fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() {
let mut h = harness(1, |_| {});
@@ -3062,7 +3118,7 @@ fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() {
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(2);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, Some(5..6));
- h.get_by_label("B").click();
+ h.get_by_label("Bold").click();
h.run_steps(5);
let id = h.state().views[0].id;
let doc = h.state().session.get(id).unwrap();
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 95443d57df..42bbe8352f 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -1986,8 +1986,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
-notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped"
+evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"]
+notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list"
[[feature]]
id = "edit.lists"
@@ -2592,8 +2592,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
-evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
-notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open"
+evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/content.rs::format_text_rows_line_up_and_fit_the_panel"]
+notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text."
[[feature]]
id = "edit.adobe-express-edits"
From 6898094e773b41249cd20ac76754fb83c9f46986 Mon Sep 17 00:00:00 2001
From: Piohrun
Date: Thu, 22 Oct 2026 01:25:43 +0200
Subject: [PATCH 17/18] M6: renaming a field renames it in the calculations
that read it (#3550)
* M6: renaming a field renames it in the calculations that read it
A rename silently broke every formula that used the field: a sum dropped
it, notation read it as 0 and a script kept a stale value. set_props now
rewrites the name in each field's Calculate script, in the same edit:
AFSimple_Calculate field lists, simplified field notation (the BVCALC
comment and its translation) and getField calls naming it in one string
literal. The rest of each script is kept as written; names built at run
time, non-UTF-8 script streams and a name another field still has are
left alone, and a self-check keeps a script whose meaning would change.
Notation is read by one lexer shared by notation_js, eval_notation and
the rename. form_set_props returns formulas_updated; Field Properties
says how many calculations now use the new name.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* M6: form_set_props leaves the renamed field out of formulas_updated by object
The renamed field was matched by the name given, but set_props stores it trimmed and under
its parent's prefix. When the same call changed the field's own formula, formulas_updated
listed the field as one the rename had rewritten.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: aiongg
---
ATTRIBUTION.toml | 44 +--
Cargo.lock | 1 +
ROADMAP.md | 2 +
crates/automation/src/forms.rs | 12 +
crates/automation/src/tools.rs | 2 +-
crates/automation/tests/automation.rs | 54 +++
crates/engine/src/lib.rs | 3 +-
crates/forms/Cargo.toml | 1 +
crates/forms/README.md | 7 +
crates/forms/src/af.rs | 469 +++++++++++++++++++++++---
crates/forms/src/author.rs | 36 ++
crates/forms/src/lib.rs | 11 +
crates/forms/src/tests.rs | 78 +++++
crates/ui-egui/src/dialogs.rs | 27 +-
crates/ui-egui/src/i18n/ar.tsv | 1 +
crates/ui-egui/src/i18n/bg.tsv | 1 +
crates/ui-egui/src/i18n/cs.tsv | 1 +
crates/ui-egui/src/i18n/de.tsv | 1 +
crates/ui-egui/src/i18n/el.tsv | 1 +
crates/ui-egui/src/i18n/es.tsv | 1 +
crates/ui-egui/src/i18n/fr.tsv | 1 +
crates/ui-egui/src/i18n/hu.tsv | 1 +
crates/ui-egui/src/i18n/it.tsv | 1 +
crates/ui-egui/src/i18n/ja.tsv | 1 +
crates/ui-egui/src/i18n/kk.tsv | 1 +
crates/ui-egui/src/i18n/lt.tsv | 1 +
crates/ui-egui/src/i18n/pl.tsv | 1 +
crates/ui-egui/src/i18n/pt-br.tsv | 1 +
crates/ui-egui/src/i18n/ru.tsv | 1 +
crates/ui-egui/src/i18n/sl.tsv | 1 +
crates/ui-egui/src/i18n/te.tsv | 1 +
crates/ui-egui/src/i18n/tr.tsv | 1 +
crates/ui-egui/src/i18n/uk.tsv | 1 +
crates/ui-egui/src/i18n/vi.tsv | 1 +
crates/ui-egui/src/i18n/zh-hans.tsv | 1 +
crates/ui-egui/src/i18n/zh-hant.tsv | 1 +
crates/ui-egui/tests/prepare.rs | 39 +++
docs/roadmap.md | 3 +-
parity/acrobat-features.toml | 4 +-
39 files changed, 732 insertions(+), 83 deletions(-)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 514b5c2f06..361a453ecc 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 = "467f1e5b55c0f036b35bb72b8a23c7cd0a8bd96ae188d742393b7dc0635166df"
+sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8"
# 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 = "4a9d4428c3f67e256aa2f9e9d2d057c0e40f60a4446a0b384f3055894a9a9213"
+sha256 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f"
[[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 = "f22298173d1e174feb83f6fb20e7079bb9091df86f396c69648b974f429be158"
+sha256 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe"
[[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 = "e320d5f50795dc1c46d589d72c7a27facb6164938cf9d75151e904b7450b142d"
+sha256 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5"
[[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 = "0dd7e0e0b0843b519cebc5504ce7006e4c9cb1eef02b270ed52d008972ee7ff3"
+sha256 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e"
[[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 = "dda5b3aea60d718eb2fe28e02a76728a3c895487e03f9319c2bcdef7ea2012db"
+sha256 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0"
[[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 = "3351f6abd5e2796ad5e70b00604c9a7093387243ad1951713a717d06bffe419d"
+sha256 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4"
[[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 = "2595a893a3cb84b6f0344b9e2144d9e956e80f778eb7d045fa2ad503e3cfb267"
+sha256 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54"
[[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 = "ec2d2afca09390c809e805a52108f5e8c94bf61feef7d0ba2901723db747323d"
+sha256 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e"
[[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 = "683f515a21dbc4b22b312c63e39b4d2a572a1f1cf733f02127f1ee62dbb7a05e"
+sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65"
[[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 = "e31d3969393994c33b90cd5fa82e80584955e4e0676df37136e345db476623ed"
+sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88"
[[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 = "fee17b5090aba63d3129772c271d9802444918fe3a905734b41e006e320e9234"
+sha256 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f"
[[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 = "ef26690aeec1c4b6b648ac50bc2433fec681524ab98e6270cabd3d76be6d3e14"
+sha256 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31"
[[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 = "2452a0f56f455ad71cae439a2ecf6da0ae5e2aaa90790de4d16e222393030283"
+sha256 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3"
[[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 = "bbf8c454edc80ddeaecce63e56f5bceba335ae6cce8e4456b14742cd3a820bfe"
+sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba"
[[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 = "64d3c1c532ecb94b44b2c894acdaf171a130cbaa8e173b099f9b9b249d2134f4"
+sha256 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98"
[[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 = "9e87a8f108b49213c7bb0f077a2e2f1bae449e1459a908d8202beba280d8055c"
+sha256 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97"
[[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 = "921e8e906bdc42675fe8922fc00835a255e019ed61688a01095367f6e881f187"
+sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c"
[[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 = "59070be5688d7d183a7354f08f9604e8f962dcc5e223dd91d1cd940997c47399"
+sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0"
[[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 = "f48accb38d4c824cb3d2f71fcffe0d8edb0a9a5f7412b9c479da6fb2479e45d2"
+sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "42212a46ddca212bb94c15062be3b444b33181ea68cdd9880fc5191f56d986a5"
+sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "fcc38245d923c4ddefb5560fe7fdfe5bc6c1b9c49d4fcb25f2873e63dff4f806"
+sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8"
[[asset]]
diff --git a/Cargo.lock b/Cargo.lock
index e9505abbc0..449b1f82b5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4177,6 +4177,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "proptest",
"thiserror 2.0.21",
]
diff --git a/ROADMAP.md b/ROADMAP.md
index 49a8b27cc9..4d84708532 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
+
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index f2eab5ddc0..bd70b65431 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -386,12 +386,24 @@ impl Automation {
} else {
edits.pop().ok_or_else(|| bad("give a field name"))?
};
+ // A rename also renames the field in the formulas that read it.
+ let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None };
let mut out = self.apply(a, edit)?;
if bulk {
out["fields"] = json!(names);
} else {
out["field"] = json!(new_name);
}
+ if let Some(before) = before {
+ let after = &self.doc(a)?.form;
+ // The renamed field itself, found by object: the name stored can differ from the
+ // argument (trimmed, kept under its parent's prefix).
+ let own =
+ names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name);
+ let mut updated = pdfcraft_engine::form_calculations_changed(&before, after);
+ updated.retain(|f| Some(f) != own);
+ out["formulas_updated"] = json!(updated);
+ }
Ok(out)
}
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index e154e1eebc..597882d2f5 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -374,7 +374,7 @@ pub fn tools() -> Vec {
t(
"form_set_props",
"Field properties",
- "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
+ "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with(schema(
json!({
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index f760398a1e..91a2656f48 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -3799,6 +3799,60 @@ fn form_formats_and_calculations_through_tools() {
));
}
+/// Renaming a field renames it in the formulas that read it: a sum dropped the field, notation
+/// read it as 0 and a script kept a stale value.
+#[test]
+fn renaming_a_field_keeps_the_formulas_that_read_it() {
+ let dir = workdir("formrename");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (i, name) in ["a", "b", "c", "d", "e", "x", "sum", "product", "double", "total"].into_iter().enumerate() {
+ let y = 20 + 30 * i;
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sum", "calculate": { "op": "sum", "fields": ["a", "b"] } }));
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "product", "calculate": { "notation": "c * d" } }));
+ ok(
+ &mut a,
+ "form_set_script",
+ json!({ "doc": doc, "field": "double", "event": "calculate", "script": "event.value = getField(\"e\").value * 2;" }),
+ );
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["x"] } }));
+ let renamed = |a: &mut Automation, from: &str, to: &str| ok(a, "form_set_props", json!({ "doc": doc, "field": from, "name": to }));
+ assert_eq!(renamed(&mut a, "a", "qty")["formulas_updated"], json!(["sum"]));
+ assert_eq!(renamed(&mut a, "c", "price")["formulas_updated"], json!(["product"]));
+ assert_eq!(renamed(&mut a, "e", "base")["formulas_updated"], json!(["double"]));
+ assert_eq!(renamed(&mut a, "total", "grand")["formulas_updated"], json!([]), "a calculated field's own name is in no formula");
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "10", "b": "2", "price": "10", "d": "4", "base": "10", "x": "9" } }));
+ let values = |a: &mut Automation| {
+ let f = ok(a, "form_fields", json!({ "doc": doc }));
+ let value = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone());
+ [value("sum"), value("product"), value("double"), value("grand")]
+ };
+ assert_eq!(values(&mut a), [Some(json!("12")), Some(json!("40")), Some(json!("20")), Some(json!("9"))]);
+ let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap().clone();
+ assert_eq!(field("sum")["calculate"]["fields"], json!(["qty", "b"]));
+ assert_eq!(field("product")["calculate"]["notation"], "price * d");
+ // The rename and the formulas it changed are one undo step: undo the fill, then the last
+ // three renames.
+ for _ in 0..4 {
+ ok(&mut a, "edit_undo", json!({ "doc": doc }));
+ }
+ let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned();
+ assert!(field("c").is_some() && field("price").is_none());
+ assert_eq!(field("product").unwrap()["calculate"]["notation"], "c * d", "undone together with the rename");
+ // The renamed field's own formula, changed in the same call, is not one the rename
+ // rewrote, though the name is stored trimmed.
+ let r = ok(
+ &mut a,
+ "form_set_props",
+ json!({ "doc": doc, "field": "sum", "name": " added ", "calculate": { "op": "sum", "fields": ["qty", "b", "x"] } }),
+ );
+ assert_eq!(r["formulas_updated"], json!([]));
+}
+
#[test]
fn tab_order_and_field_appearance_through_tools() {
let dir = workdir("taborder");
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index dfe11b25c6..cdc7eb646f 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -58,7 +58,8 @@ pub use pdfcraft_edit::{
pub use pdfcraft_forms::{
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
- appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
+ appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed,
+ flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
diff --git a/crates/forms/Cargo.toml b/crates/forms/Cargo.toml
index 3f085ee6cc..fcecbb5f87 100644
--- a/crates/forms/Cargo.toml
+++ b/crates/forms/Cargo.toml
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
[dev-dependencies]
hayro-syntax = { workspace = true }
+proptest = { workspace = true }
diff --git a/crates/forms/README.md b/crates/forms/README.md
index 66fb919fe7..2ba38097ed 100644
--- a/crates/forms/README.md
+++ b/crates/forms/README.md
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
buttons joining a group become kid widgets with their own export state. Renaming changes only
the last part of a dotted name.
+- Renaming a field renames it in every Calculate script that reads it
+ (`af::rename_in_calculation`): `AFSimple_Calculate` field lists, simplified field notation
+ (the `BVCALC` comment and its translation), and `getField` calls whose argument is the name as
+ one string literal. The rest of each script is kept as written. Names built at run time, a
+ script stream that isn't UTF-8, other events' scripts and a name another field still has are
+ left alone. `calculations_changed` compares two readings of a form to say which formulas an
+ edit rewrote.
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
that label, independently of the field name. The index is 0-based. A label-only edit redraws
diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs
index c5b81719ac..ab7fc327c9 100644
--- a/crates/forms/src/af.rs
+++ b/crates/forms/src/af.rs
@@ -11,6 +11,7 @@
use std::collections::VecDeque;
use std::fmt::Write as _;
+use std::ops::Range;
/// The Format tab.
#[derive(Clone, Debug, PartialEq, Default)]
@@ -453,6 +454,250 @@ pub fn calculate_js(c: &Calculate) -> Option {
}
}
+// ── renaming fields in calculations ─────────────────────────────────────────────────────────
+
+/// A JavaScript token, as far as finding the field names a calculation reads needs.
+#[derive(Clone, Debug, PartialEq)]
+enum Js {
+ Ident,
+ /// A string literal, its escapes undone.
+ Str(String),
+ Punct(char),
+ /// A number, a regular expression or a template literal.
+ Other,
+}
+
+/// Words after which `/` starts a regular expression instead of dividing.
+const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"];
+
+/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell
+/// code from strings, comments and regular expressions. The tokens stop where a string, comment
+/// or template literal never ends.
+fn js_tokens(js: &str) -> Vec<(Range, Js)> {
+ let s = js.as_bytes();
+ // Bytes past ASCII are parts of identifiers, so a range never ends inside a character.
+ let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii();
+ let mut out: Vec<(Range, Js)> = Vec::new();
+ let mut i = 0;
+ // Where a regular expression that never ended stopped being read: every `/` before it
+ // divides, so a line of them is read once, not once for each.
+ let mut divides_until = 0;
+ 'scan: while let Some(&c) = s.get(i) {
+ let start = i;
+ let token = match c {
+ _ if c.is_ascii_whitespace() => {
+ i += 1;
+ continue;
+ }
+ b'/' if s.get(i + 1) == Some(&b'/') => {
+ i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n);
+ continue;
+ }
+ b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) {
+ Some(n) => {
+ i += n + 4;
+ continue;
+ }
+ None => break 'scan,
+ },
+ b'"' | b'\'' => {
+ let mut p = Parser { s, i, depth: 0 };
+ let Some(text) = p.string() else { break 'scan };
+ i = p.i;
+ Js::Str(text)
+ }
+ b'`' => {
+ let mut j = i + 1;
+ loop {
+ match s.get(j) {
+ None => break 'scan,
+ Some(b'\\') => j += 2,
+ Some(b'`') => break,
+ Some(_) => j += 1,
+ }
+ }
+ i = j + 1;
+ Js::Other
+ }
+ b'/' if i >= divides_until && regex_may_follow(js, &out) => {
+ // A regular expression ends at a `/` outside a class; a line break first means it
+ // was a division after all.
+ let (mut j, mut class) = (i + 1, false);
+ let end = loop {
+ match s.get(j) {
+ None | Some(b'\n') => break None,
+ Some(b'\\') => j += 2,
+ Some(b'/') if !class => break Some(j),
+ Some(b) => {
+ class = match b {
+ b'[' => true,
+ b']' => false,
+ _ => class,
+ };
+ j += 1;
+ }
+ }
+ };
+ match end {
+ Some(e) => {
+ i = e + 1;
+ while s.get(i).is_some_and(|b| word(*b)) {
+ i += 1;
+ }
+ Js::Other
+ }
+ None => {
+ divides_until = j;
+ i += 1;
+ Js::Punct('/')
+ }
+ }
+ }
+ _ if c.is_ascii_digit() => {
+ while s.get(i).is_some_and(|b| word(*b) || *b == b'.') {
+ i += 1;
+ }
+ Js::Other
+ }
+ _ if word(c) => {
+ while s.get(i).is_some_and(|b| word(*b)) {
+ i += 1;
+ }
+ Js::Ident
+ }
+ _ => {
+ i += 1;
+ Js::Punct(char::from(c))
+ }
+ };
+ out.push((start..i, token));
+ }
+ out
+}
+
+/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening
+/// bracket or a keyword such as `return`, not after a value.
+fn regex_may_follow(js: &str, tokens: &[(Range, Js)]) -> bool {
+ match tokens.last() {
+ None => true,
+ Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)),
+ Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'),
+ Some((_, Js::Str(_) | Js::Other)) => false,
+ }
+}
+
+/// A place where a Calculate script names a field.
+#[derive(Clone, Debug, PartialEq)]
+enum Site {
+ /// A string literal holding one name: `getField("a")`, or an item of an
+ /// `AFSimple_Calculate` list.
+ Literal(String),
+ /// An `AFSimple_Calculate` list written as one string of comma-separated names.
+ Commas(String),
+ /// A name in the simplified field notation of a `BVCALC` comment.
+ Notation(String),
+}
+
+impl Site {
+ fn names(&self) -> Vec<&str> {
+ match self {
+ Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
+ Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
+ }
+ }
+
+ /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`.
+ fn renamed(&self, old: &str, new: &str) -> Option {
+ match self {
+ Site::Literal(name) => (name == old).then(|| js_str(new)),
+ Site::Notation(name) => (name == old).then(|| notation_name(new)),
+ Site::Commas(list) => {
+ let parts: Vec<&str> = list.split(',').collect();
+ parts.iter().any(|p| p.trim() == old).then(|| {
+ // The spaces around each name stay as they were.
+ let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect();
+ js_str(&parts.join(","))
+ })
+ }
+ }
+ }
+}
+
+/// Where a Calculate script names fields, in order: the simplified field notation between
+/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), the field list of
+/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
+/// A name built at run time (`getField("row" + i)`) is not a site.
+fn calculation_sites(js: &str) -> Vec<(Range, Site)> {
+ let mut sites = Vec::new();
+ if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC"))
+ && let Some(expr) = js.get(a + 6..b)
+ {
+ for (range, token) in notation_tokens(expr) {
+ if let Notation::Name(name) = token {
+ sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name)));
+ }
+ }
+ }
+ let tokens = js_tokens(js);
+ let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word));
+ let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c));
+ let string = |k: usize| match tokens.get(k) {
+ Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
+ _ => None,
+ };
+ for k in 0..tokens.len() {
+ if ident(k, "getField")
+ && punct(k + 1, '(')
+ && punct(k + 3, ')')
+ && let Some((range, name)) = string(k + 2)
+ {
+ sites.push((range, Site::Literal(name)));
+ }
+ if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
+ continue;
+ }
+ if let Some((range, list)) = string(k + 4) {
+ sites.push((range, Site::Commas(list)));
+ continue;
+ }
+ // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
+ // Nothing else is read, so no token is read for two calls.
+ let first = if punct(k + 4, '[') {
+ k + 5
+ } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
+ k + 7
+ } else {
+ continue;
+ };
+ for (range, token) in tokens.iter().skip(first) {
+ match token {
+ Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
+ Js::Punct(',') => {}
+ _ => break,
+ }
+ }
+ }
+ sites.sort_by_key(|(range, _)| range.start);
+ sites
+}
+
+/// A Calculate script with field `old` named `new` everywhere it reads it (see
+/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also
+/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end
+/// the notation early), so a script is never left meaning something else.
+pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option {
+ let sites = calculation_sites(js);
+ let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect();
+ if edits.is_empty() {
+ return None;
+ }
+ let out = splice(js, edits);
+ let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect();
+ let written = calculation_sites(&out);
+ let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect();
+ (got == expected).then_some(out)
+}
+
// ── running them ────────────────────────────────────────────────────────────────────────────
/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a
@@ -960,77 +1205,107 @@ pub fn validate(v: &Validate, value: &str) -> Result<(), String> {
})
}
-/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
-/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
-pub fn notation_js(expr: &str) -> String {
- let mut out = String::new();
- let cs: Vec = expr.chars().collect();
- let mut i = 0;
- while let Some(&c) = cs.get(i) {
- if c.is_whitespace() || "+-*/()".contains(c) {
- out.push(c);
- i += 1;
+/// A token of simplified field notation.
+#[derive(Clone, Debug, PartialEq)]
+enum Notation {
+ /// A number, as written.
+ Number,
+ /// `+ - * / ( )`.
+ Op(char),
+ /// A field name, its `\` escapes undone.
+ Name(String),
+}
+
+/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates
+/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them.
+fn notation_tokens(src: &str) -> Vec<(Range, Notation)> {
+ let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c);
+ let mut out = Vec::new();
+ let mut cs = src.char_indices().peekable();
+ while let Some(&(start, c)) = cs.peek() {
+ let mut end = start + c.len_utf8();
+ if c.is_whitespace() {
+ cs.next();
+ } else if breaks(c) {
+ cs.next();
+ out.push((start..end, Notation::Op(c)));
} else if c.is_ascii_digit() || c == '.' {
- while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') {
- out.push(d);
- i += 1;
+ while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') {
+ end = i + d.len_utf8();
}
+ out.push((start..end, Notation::Number));
} else {
let mut name = String::new();
- while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) {
- // `\` escapes a space or an operator inside a name, as Acrobat writes them.
+ while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) {
+ end = i + n.len_utf8();
if n == '\\'
- && let Some(&next) = cs.get(i + 1)
+ && let Some((j, next)) = cs.next()
{
+ end = j + next.len_utf8();
name.push(next);
- i += 2;
- continue;
+ } else {
+ name.push(n);
}
- name.push(n);
- i += 1;
}
- out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name)));
+ out.push((start..end, Notation::Name(name)));
}
}
out
}
-/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names
-/// (`\` escapes spaces and operators in names, as Acrobat writes them).
+/// A field name as simplified field notation writes it: `\` before a space, an operator or a
+/// `\`, and before a first character that would start a number.
+fn notation_name(name: &str) -> String {
+ let mut out = String::new();
+ for (i, c) in name.chars().enumerate() {
+ if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) {
+ out.push('\\');
+ }
+ out.push(c);
+ }
+ out
+}
+
+/// `src` with each range replaced. The ranges come in order and don't overlap; one that does
+/// is left out rather than written over another.
+fn splice(src: &str, edits: impl IntoIterator, String)>) -> String {
+ let mut out = String::with_capacity(src.len());
+ let mut done = 0;
+ for (range, with) in edits {
+ let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue };
+ out.push_str(kept);
+ out.push_str(&with);
+ done = range.end;
+ }
+ out.push_str(src.get(done..).unwrap_or_default());
+ out
+}
+
+/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
+/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
+pub fn notation_js(expr: &str) -> String {
+ let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t {
+ Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))),
+ _ => None,
+ });
+ splice(expr, names)
+}
+
+/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names.
fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option {
#[derive(Debug, Clone, PartialEq)]
enum T {
N(f64),
Op(char),
}
- let mut toks = Vec::new();
- let cs: Vec = src.chars().collect();
- let mut i = 0;
- while i < cs.len() {
- let c = cs[i];
- if c.is_whitespace() {
- i += 1;
- } else if "+-*/()".contains(c) {
- toks.push(T::Op(c));
- i += 1;
- } else if c.is_ascii_digit() || c == '.' {
- let s = i;
- while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') {
- i += 1;
- }
- toks.push(T::N(cs[s..i].iter().collect::().parse().ok()?));
- } else {
- let mut name = String::new();
- while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) {
- if cs[i] == '\\' && i + 1 < cs.len() {
- i += 1;
- }
- name.push(cs[i]);
- i += 1;
- }
- toks.push(T::N(value_of(&name)));
- }
- }
+ let toks = notation_tokens(src)
+ .into_iter()
+ .map(|(range, t)| match t {
+ Notation::Number => src.get(range)?.parse().ok().map(T::N),
+ Notation::Op(c) => Some(T::Op(c)),
+ Notation::Name(name) => Some(T::N(value_of(&name))),
+ })
+ .collect::