diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 3ed95af6b..9aadfc938 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "3066b34500d662ac6515806784a20b3b6f39d708dbaea26204995323c0a841f0"
+sha256 = "55e7300dbffa2f74cff0fedbf9411ce7b7465e2a871eabfeb691ac076abdda94"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "b8d2b959ae42cd0d1b823d958142f997891ce463e6075b37614536a51b63c190"
+sha256 = "a147d7ea988620f64cf5e24069187d633f79c9bcd71e86788a0692a89bd415a3"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "e336657e76e498264691f4c55af84ae69af803bae2564e26f87a2bbad31fdf8e"
+sha256 = "d59a3fe00045668545ec5643ab3516f38fa004eacdedfe15cdca16a8af40722d"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "972ca1974635243d199fc6b3d2c77ab78ff5553674231f324bdbff9575035fd7"
+sha256 = "ba0b707dedeb94595f8e11d0d4ce341223344c33208f1a113f8feab6a956f752"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "a7e06aac931016658d143c0ce0246fcfa55bf8d88eee3b25162fcfa12eebd263"
+sha256 = "b39691a4d106c67668adf9763ae4613bb3aa74caf45592bd3cf1e941473c04b0"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "4120337665604b2799961383d44ab0e01413da5685b233c9051f4ab1a8234790"
+sha256 = "5b6a7b7e61969efb0a32fba1ab85671d03ebf14f2d274647e5e4ad1a76e128da"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,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 = "0856ff8e37cb40b44f90ff122d638aca6b94c528994abd3b360f7ff01b74508e"
+sha256 = "3742cd185a9fc70021a7fee4fb0a159896a3e29c35a372bec059b277f978c689"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,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 = "1d891722c4bb69968eb3fca2c479ba9cf0d68fd6b2f0f3a5975f154246bdde0e"
+sha256 = "272f449649c3cc9c71dec77b20fcd912f5e00b828aa2c6ca91735d3aae876203"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,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 = "f0cdcfafe37d2be636ede1cef251fc5028ad233e020be8614727a676cc93283d"
+sha256 = "b45e7d7e49987852dc2b8262c2738f8b51defe6832c917e07dae0374af936013"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -118,7 +118,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 = "3d95becdff6b37d8c1bb878578f6afcb0790f6519154e2dcef323344923abebe"
+sha256 = "7da3706da8c7eae25685499d04707bbb781378707130481059a98a4e76ff0e16"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -129,7 +129,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 = "b1679b1fca5008f0a7d882204863ddfc9574195f2da0b1d8ef7d1b63b43d00bc"
+sha256 = "16d327b8b2c445d592e29cfcee862c47e5015dc720e689157ae9bf936373ee04"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -140,7 +140,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 = "5970a230e1bfaf9ee0e0eaa25af6d58017141a88a4859eb9fd7cd47282723d55"
+sha256 = "9cafc2c56166d8b33176af66fa8118588e21aaa2f8df1b91f7bb215b2ad8b7e8"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -151,7 +151,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 = "428c8729b83bf044cc2134f6fd4b130b38c9072a3c488889cf1adb6b3df1352c"
+sha256 = "460521cbf67f5bfead4de294e46fd2d8aa7fcab86cffab4b79cc7c80417091a4"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -162,7 +162,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 = "98ade2903b213f16e9ee196aac9d35c54b923e20750cec84343d53f0ee655fed"
+sha256 = "7855b47b221290f47560bf00238122086fdc2ecf55b130ee7bedb4a25421f678"
[[asset]]
path = "assets/icons/accessibility.svg"
diff --git a/ROADMAP.md b/ROADMAP.md
index 796e2c0ff..0df694934 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -100,7 +100,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
-| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
+| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
@@ -131,6 +131,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
+- **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%).
+
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%).
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 3e220b677..75f9ee21c 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -28,6 +28,22 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
+## Shared field properties
+
+`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
+
+```json
+{"doc": 1, "fields": ["first_name", "last_name"], "required": true,
+ "appearance": {"width": 3, "border": "#1473E6"}}
+```
+
+The batch is atomic and creates one undo step. A missing or locked field, invalid option,
+unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
+unlock and change fields together. Only supplied properties change: each field/widget keeps
+its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
+or fill colour. Names, rectangles and rotations require the single-field form. Results keep
+`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
+
## MCP server
**Opt-in only.** Nothing starts it automatically, and it opens no port. It runs while `pdfcraft-cli mcp [--root DIR]` runs, normally launched by an agent from its MCP configuration. It stops when stdin closes. The CLI's `mcp` Cargo feature (on by default) compiles it out entirely when disabled.
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index eb3f1b381..e6334489c 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -43,9 +43,14 @@ fn format_arg(v: &Value) -> Result {
/// `{"border": "#FF0000" | "none", "fill": …, "width": 1-3, "style": "solid"|…, "text_color": …,
/// "font": "helvetica"|"times"|"courier"}`, changing only what is given.
-fn look_arg(v: &Value, mut l: pdfcraft_engine::FieldLook) -> Result {
- use pdfcraft_engine::{BorderStyle, FieldFont};
+fn look_arg(v: &Value) -> Result {
+ use pdfcraft_engine::{BorderStyle, FieldFont, FieldLookPatch};
let o = v.as_object().ok_or_else(|| bad("appearance must be an object"))?;
+ for key in o.keys() {
+ if !["border", "fill", "text_color", "width", "style", "font"].contains(&key.as_str()) {
+ return Err(bad(format!("unknown appearance property {key:?}")));
+ }
+ }
let colour = |k: &str| -> Result>> {
match o.get(k) {
None => Ok(None),
@@ -54,37 +59,39 @@ fn look_arg(v: &Value, mut l: pdfcraft_engine::FieldLook) -> Result Err(bad(format!("{k} must be a colour or \"none\""))),
}
};
- if let Some(c) = colour("border")? {
- l.border = c;
- }
- if let Some(c) = colour("fill")? {
- l.fill = c;
- }
- if let Some(c) = colour("text_color")? {
- l.text = c.unwrap_or([0.0; 3]);
- }
- if let Some(w) = o.get("width").and_then(Value::as_f64) {
- l.width = w.clamp(0.0, 12.0);
- }
- if let Some(s) = o.get("style").and_then(Value::as_str) {
- l.style = match s {
- "solid" => BorderStyle::Solid,
- "dashed" => BorderStyle::Dashed,
- "beveled" => BorderStyle::Beveled,
- "inset" => BorderStyle::Inset,
- "underline" => BorderStyle::Underline,
- x => return Err(bad(format!("unknown style {x:?}"))),
- };
- }
- if let Some(f) = o.get("font").and_then(Value::as_str) {
- l.font = match f {
- "helvetica" => FieldFont::Helvetica,
- "times" => FieldFont::Times,
- "courier" => FieldFont::Courier,
- x => return Err(bad(format!("unknown font {x:?} (helvetica, times, courier)"))),
- };
- }
- Ok(l)
+ let string =
+ |k: &str| -> Result> { o.get(k).map(|v| v.as_str().ok_or_else(|| bad(format!("appearance.{k} must be a string")))).transpose() };
+ Ok(FieldLookPatch {
+ border: colour("border")?,
+ fill: colour("fill")?,
+ text: colour("text_color")?.map(|c| c.unwrap_or([0.0; 3])),
+ width: o
+ .get("width")
+ .map(|v| {
+ v.as_f64()
+ .filter(|v| v.is_finite() && (0.0..=12.0).contains(v))
+ .ok_or_else(|| bad("appearance.width must be between 0 and 12 points"))
+ })
+ .transpose()?,
+ style: string("style")?
+ .map(|s| match s {
+ "solid" => Ok(BorderStyle::Solid),
+ "dashed" => Ok(BorderStyle::Dashed),
+ "beveled" => Ok(BorderStyle::Beveled),
+ "inset" => Ok(BorderStyle::Inset),
+ "underline" => Ok(BorderStyle::Underline),
+ x => Err(bad(format!("unknown style {x:?}"))),
+ })
+ .transpose()?,
+ font: string("font")?
+ .map(|s| match s {
+ "helvetica" => Ok(FieldFont::Helvetica),
+ "times" => Ok(FieldFont::Times),
+ "courier" => Ok(FieldFont::Courier),
+ x => Err(bad(format!("unknown font {x:?} (helvetica, times, courier)"))),
+ })
+ .transpose()?,
+ })
}
/// `{"min": 0, "max": 100}` or `"none"`.
@@ -320,7 +327,59 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result {
- let name = a.str("field")?.to_owned();
+ let single = a.opt_str("field")?;
+ let bulk = a.get("fields").is_some();
+ if single.is_some() == bulk {
+ return Err(bad("give either field or fields, not both"));
+ }
+ let names: Vec = if bulk {
+ let count = a.get("fields").and_then(Value::as_array).ok_or_else(|| bad("fields must be an array of field names"))?.len();
+ if count == 0 || count > 1_000 {
+ return Err(bad("fields must contain between 1 and 1000 names"));
+ }
+ let names = a.strs("fields")?;
+ let mut seen = std::collections::HashSet::new();
+ for name in &names {
+ if !seen.insert(*name) {
+ return Err(bad(format!("field {name:?} is listed more than once")));
+ }
+ }
+ for key in ["name", "rect", "rotation"] {
+ if a.get(key).is_some() {
+ return Err(bad(format!("{key} applies to one field; use field instead of fields")));
+ }
+ }
+ names.into_iter().map(str::to_owned).collect()
+ } else {
+ single.into_iter().map(str::to_owned).collect()
+ };
+ let mut edits = Vec::with_capacity(names.len());
+ let mut new_name = None;
+ for name in &names {
+ // Merge appearance changes separately for each field: changing just the border
+ // must not copy the first field's fill, font or text colour onto the others.
+ let props = self.field_props_arg(a, name)?;
+ if props == FieldProps::default() {
+ return Err(bad("nothing to change"));
+ }
+ new_name = Some(props.name.clone().unwrap_or_else(|| name.clone()));
+ edits.push(Edit::SetFieldProps { name: name.clone(), props: Box::new(props) });
+ }
+ let edit = if bulk {
+ Edit::Batch { label: "Change field properties".into(), edits }
+ } else {
+ edits.pop().ok_or_else(|| bad("give a field name"))?
+ };
+ let mut out = self.apply(a, edit)?;
+ if bulk {
+ out["fields"] = json!(names);
+ } else {
+ out["field"] = json!(new_name);
+ }
+ Ok(out)
+ }
+
+ fn field_props_arg(&self, a: &Args, name: &str) -> Result {
let doc = self.doc(a)?;
let f = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
// Position: top-left-origin points on the displayed page → user space.
@@ -335,7 +394,7 @@ impl Automation {
Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
}
};
- let props = FieldProps {
+ Ok(FieldProps {
rect,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
@@ -350,10 +409,8 @@ impl Automation {
},
options: a.get("options").map(|_| a.strs("options")).transpose()?.map(|v| v.into_iter().map(str::to_owned).collect()),
font_size: a.opt_num("font_size")?,
- look: match a.get("appearance") {
- None => None,
- Some(v) => Some(look_arg(v, self.doc(a)?.field_look(&name).ok_or_else(|| failed("this field has no widget"))?)?),
- },
+ look: None,
+ appearance: a.get("appearance").map(look_arg).transpose()?.filter(|p| *p != pdfcraft_engine::FieldLookPatch::default()),
format: a.get("format").map(format_arg).transpose()?,
validate: a.get("validate").map(validate_arg).transpose()?,
calculate: a.get("calculate").map(calculate_arg).transpose()?,
@@ -410,14 +467,7 @@ impl Automation {
.ok_or_else(|| bad(format!("unknown check style {s:?} (check, circle, cross, diamond, square, star)")))?,
),
},
- };
- if props == FieldProps::default() {
- return Err(ToolError::InvalidArgs("nothing to change".into()));
- }
- let new_name = props.name.clone().unwrap_or(name.clone());
- let mut out = self.apply(a, Edit::SetFieldProps { name, props: Box::new(props) })?;
- out["field"] = json!(new_name);
- Ok(out)
+ })
}
pub(crate) fn form_tab_order(&mut self, a: &Args) -> Result {
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 9fa6a54a9..604ec20f4 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -303,12 +303,14 @@ pub fn tools() -> Vec {
t(
"form_set_props",
"Field properties",
- "Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), rotation (0, 90, 180 or 270 degrees counterclockwise; changing between horizontal and vertical swaps width and height around the center), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons 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 and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. 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) and rotation require a single field. 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(
+ .with({
+ let mut input = schema(
json!({
"doc": doc(),
"field": { "type": "string", "description": "The field's current name." },
+ "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
"read_only": { "type": "boolean" },
@@ -324,13 +326,16 @@ pub fn tools() -> Vec {
"font_size": { "type": "number", "minimum": 0 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
- "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
+ "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
}),
- &["doc", "field"],
- )),
+ &["doc"],
+ );
+ input["oneOf"] = json!([{ "required": ["field"] }, { "required": ["fields"] }]);
+ input
+ }),
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
.with(schema(
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/form_bulk.rs
new file mode 100644
index 000000000..9dcaaef75
--- /dev/null
+++ b/crates/automation/tests/form_bulk.rs
@@ -0,0 +1,152 @@
+//! End-to-end bulk Field Properties through the same tools used by CLI and MCP.
+
+use pdfcraft_automation::{Automation, Content, ToolError};
+use pdfcraft_engine::{DocId, Edit, FieldFont, FieldProps, FieldValue, NewField, Session, field_flags};
+use serde_json::{Value, json};
+
+fn call(a: &mut Automation, args: Value) -> Value {
+ match a.call("form_set_props", &args).unwrap().remove(0) {
+ Content::Json(v) => v,
+ other => panic!("expected JSON: {other:?}"),
+ }
+}
+
+fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
+ match a.call(name, &args).unwrap().remove(0) {
+ Content::Json(v) => v,
+ other => panic!("expected JSON: {other:?}"),
+ }
+}
+
+fn setup(test: &str) -> (Automation, u64, std::path::PathBuf) {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-form-bulk-{test}-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let mut s = Session::new();
+ let bytes = s.create_blank(300.0, 400.0, 2).unwrap();
+ let id = s.open("form.pdf", None, bytes, None).unwrap();
+ for (name, page, x) in [("alpha", 0, 30.0), ("beta", 1, 40.0)] {
+ s.apply(id, Edit::AddField { page, rect: [x, 300.0, x + 180.0, 324.0], kind: NewField::Text { multiline: false }, name: Some(name.into()) })
+ .unwrap();
+ s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text(name.into()) }).unwrap();
+ let mut look = s.get(id).unwrap().field_look(name).unwrap();
+ look.fill = Some(if name == "alpha" { [1.0, 0.9, 0.8] } else { [0.8, 0.9, 1.0] });
+ look.font = if name == "alpha" { FieldFont::Courier } else { FieldFont::Times };
+ s.apply(
+ id,
+ Edit::SetFieldProps {
+ name: name.into(),
+ props: Box::new(FieldProps { look: Some(look), tooltip: Some(name.into()), ..Default::default() }),
+ },
+ )
+ .unwrap();
+ }
+ std::fs::write(dir.join("form.pdf"), s.save_bytes(id).unwrap().as_slice()).unwrap();
+ let mut a = Automation::new().with_root(&dir).unwrap();
+ let id = tool(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap();
+ (a, id, dir)
+}
+
+fn bytes(a: &Automation, id: u64) -> Vec {
+ a.session().save_bytes(DocId(id)).unwrap().as_ref().clone()
+}
+
+#[test]
+fn bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save() {
+ let (mut a, id, dir) = setup("roundtrip");
+ let before = bytes(&a, id);
+ let original =
+ [a.session().get(DocId(id)).unwrap().field_look("alpha").unwrap(), a.session().get(DocId(id)).unwrap().field_look("beta").unwrap()];
+ let out = call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "required": true, "appearance": {"width": 4, "border": "#FF0000"}}));
+ assert_eq!(out["fields"], json!(["alpha", "beta"]));
+ let doc = a.session().get(DocId(id)).unwrap();
+ assert_eq!(doc.can_undo(), Some("Change field properties"));
+ for (name, look) in [("alpha", original[0]), ("beta", original[1])] {
+ let changed = doc.field_look(name).unwrap();
+ assert_eq!(changed.width, 4.0);
+ assert_eq!(changed.border, Some([1.0, 0.0, 0.0]));
+ assert_eq!((changed.fill, changed.font, changed.text), (look.fill, look.font, look.text));
+ let f = doc.form.iter().find(|f| f.name == name).unwrap();
+ assert!(f.has(field_flags::REQUIRED));
+ assert_eq!(f.value, [name]);
+ assert_eq!(f.tooltip.as_deref(), Some(name));
+ }
+ tool(&mut a, "edit_undo", json!({"doc": id}));
+ assert_eq!(bytes(&a, id), before);
+ tool(&mut a, "edit_redo", json!({"doc": id}));
+ tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
+ let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
+ let doc = a.session().get(DocId(reopened)).unwrap();
+ assert_eq!(doc.field_look("beta").unwrap().font, original[1].font);
+ assert_eq!(doc.field_look("beta").unwrap().width, 4.0);
+ assert_eq!(doc.form.iter().find(|f| f.name == "beta").unwrap().value, ["beta"]);
+ let image = a.call("page_render", &json!({"doc": reopened, "page": 2, "dpi": 72})).unwrap();
+ assert!(image.iter().any(|c| matches!(c, Content::Png { width: 300, height: 400, .. })));
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn bulk_property_arguments_are_strict_and_fail_without_any_edit() {
+ let (mut a, id, dir) = setup("invalid");
+ let before = bytes(&a, id);
+ let cases = [
+ json!({"doc": id, "required": true}),
+ json!({"doc": id, "field": "alpha", "fields": ["beta"], "required": true}),
+ json!({"doc": id, "fields": [], "required": true}),
+ json!({"doc": id, "fields": ["alpha", "alpha"], "required": true}),
+ json!({"doc": id, "fields": ["alpha", 7], "required": true}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "name": "same"}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "rotation": 90}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "rect": [0, 0, 20, 20]}),
+ json!({"doc": id, "fields": ["alpha", "beta"]}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "required": "yes"}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"style": "invalid"}}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"width": -1}}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"width": "thick"}}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"style": 3}}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"font": true}}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"unknown": true}}),
+ json!({"doc": id, "fields": ["alpha", "beta"], "requred": true}),
+ json!({"doc": id, "fields": vec!["alpha"; 1001], "required": true}),
+ ];
+ for args in cases {
+ assert!(matches!(a.call("form_set_props", &args), Err(ToolError::InvalidArgs(_))), "{args}");
+ assert_eq!(bytes(&a, id), before, "{args}");
+ }
+ assert!(matches!(a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "missing"], "required": true})), Err(ToolError::Failed(_))));
+ assert_eq!(bytes(&a, id), before);
+ assert!(a.session().get(DocId(id)).unwrap().can_undo().is_none());
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn a_locked_last_field_rolls_back_earlier_changes_and_unlocks_with_the_batch() {
+ let (mut a, id, dir) = setup("locked");
+ call(&mut a, json!({"doc": id, "field": "beta", "locked": true}));
+ let before = bytes(&a, id);
+ assert!(matches!(
+ a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "beta"], "tooltip": "Shared help"})),
+ Err(ToolError::Failed(_))
+ ));
+ assert_eq!(bytes(&a, id), before);
+ assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
+ call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "locked": false, "tooltip": "Shared help"}));
+ assert!(a.session().get(DocId(id)).unwrap().form.iter().all(|f| !f.locked() && f.tooltip.as_deref() == Some("Shared help")));
+ tool(&mut a, "edit_undo", json!({"doc": id}));
+ assert_eq!(bytes(&a, id), before);
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
+ let (mut a, id, dir) = setup("options");
+ let before = bytes(&a, id);
+ assert!(matches!(a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "beta"], "flags": {"comb": true}})), Err(ToolError::Failed(_))));
+ assert_eq!(bytes(&a, id), before);
+ call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "max_length": 20, "align": "center", "flags": {"comb": true, "spell_check": false}}));
+ let doc = a.session().get(DocId(id)).unwrap();
+ assert!(doc.form.iter().all(|f| f.max_len == Some(20) && f.quadding == 1 && f.has(field_flags::COMB) && f.has(field_flags::DO_NOT_SPELL_CHECK)));
+ assert_eq!(doc.form.iter().map(|f| f.value[0].as_str()).collect::>(), ["alpha", "beta"]);
+ tool(&mut a, "edit_undo", json!({"doc": id}));
+ assert_eq!(bytes(&a, id), before);
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs
index ebab32371..a3efd7877 100644
--- a/crates/engine/src/dates.rs
+++ b/crates/engine/src/dates.rs
@@ -504,6 +504,8 @@ mod tests {
assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026");
assert_eq!(in_lang("ddd d mmm", "pt-br"), "sáb 7 mar");
assert_eq!(in_lang("dddd d mmmm yyyy", "fr"), "samedi 7 mars 2026");
+ assert_eq!(in_lang("dddd d mmmm yyyy", "it"), "sabato 7 marzo 2026");
+ assert_eq!(in_lang("ddd d mmm", "it"), "sab 7 mar");
assert_eq!(in_lang("dddd, d mmmm", "te"), "శనివారం, 7 మార్చి");
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日");
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六");
@@ -538,6 +540,9 @@ mod tests {
assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November"));
assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages");
assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one");
+ s.set_date_language(Some("IT")).unwrap();
+ assert_eq!(s.date_language(), Some("it"));
+ assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF");
s.set_date_language(None).unwrap();
assert_eq!(s.date_language(), None);
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 4e38e8d32..d76118d1e 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -41,7 +41,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, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, flags as field_flags,
+ Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
+ flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
diff --git a/crates/forms/README.md b/crates/forms/README.md
index 0fa6d0de0..fa475fa7d 100644
--- a/crates/forms/README.md
+++ b/crates/forms/README.md
@@ -16,6 +16,11 @@ reset(&mut doc, None)?; // Clear form (back
// Prepare a form
let name = add_field(&mut doc, 0, [72.0, 700.0, 216.0, 722.0], &NewField::Text { multiline: false }, None)?; // "Text1"
set_props(&mut doc, &name, &FieldProps { name: Some("email".into()), required: Some(true), ..Default::default() })?;
+// A partial appearance edit keeps each widget's other styles and custom /DA font.
+set_props(&mut doc, "email", &FieldProps {
+ appearance: Some(LookPatch { width: Some(3.0), fill: Some(None), ..Default::default() }),
+ ..Default::default()
+})?;
delete_field(&mut doc, "email")?;
```
@@ -30,6 +35,11 @@ delete_field(&mut doc, "email")?;
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
- Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a
failed call changes nothing.
+- `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps
+ a property; `Some(None)` removes a border/fill colour. Unchanged `/DA`, custom font resources,
+ widget-specific `/DA` overrides, indirect `/MK` and `/BS` contents, dash arrays and unknown
+ dictionary keys are preserved. It is mutually exclusive with the complete `look` replacement.
+ Use the engine's `Edit::Batch` to change several fields atomically in one undo step.
- `/RV` (rich text) is removed when a value is set so it can't contradict `/V`.
- New fields follow Acrobat: names Text1, Check Box1, Group1 (radio groups), Dropdown1, List Box1,
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
@@ -38,7 +48,7 @@ delete_field(&mut doc, "email")?;
## Not yet
-JavaScript actions (format, keystroke, validate, calculate; M6.4/M6.5), rich-text values,
-the Appearance (colours, fonts), Actions, Format, Validate and Calculate property tabs,
-FDF/XFDF import and export (M6.7). `/MK /R` rotation (0, 90, 180, 270) is drawn into the
+Rich-text values and exact appearance metrics/layout remain incomplete. JavaScript
+execution and FDF/XFDF exchange are integrated through the engine; this crate provides
+field actions and native AF rules. `/MK /R` rotation (0, 90, 180, 270) is drawn into the
appearance.
diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs
index 53af944aa..e75beeb3b 100644
--- a/crates/forms/src/author.rs
+++ b/crates/forms/src/author.rs
@@ -75,6 +75,9 @@ pub struct FieldProps {
pub rect: Option<(usize, [f64; 4])>,
/// Appearance tab (border, fill, line, text colour, font).
pub look: Option,
+ /// Change only these appearance properties. Unlike `look`, this preserves each widget's
+ /// other colours, border settings and default appearance, including custom font resources.
+ pub appearance: Option,
/// Format, Validate and Calculate tabs (written as Acrobat's AF scripts).
pub format: Option,
pub validate: Option,
@@ -161,6 +164,129 @@ pub struct Look {
pub font: FieldFont,
}
+/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a
+/// border or fill colour. The patch is applied to each widget's own dictionaries.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct LookPatch {
+ pub border: Option>,
+ pub fill: Option >,
+ pub width: Option,
+ pub style: Option,
+ pub text: Option<[f64; 3]>,
+ pub font: Option,
+}
+
+impl LookPatch {
+ fn validate(&self) -> Result<(), FormError> {
+ for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) {
+ if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) {
+ return invalid("appearance colours must be finite RGB values between 0 and 1");
+ }
+ }
+ if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) {
+ return invalid("border width must be between 0 and 12 points");
+ }
+ Ok(())
+ }
+}
+
+/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last
+/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a
+/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't
+/// grow the string.
+fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String {
+ if patch.font.is_none() && size.is_none() && patch.text.is_none() {
+ return da.to_string();
+ }
+ let mut toks: Vec = da.split_whitespace().map(str::to_string).collect();
+ let numeric = |s: &str| s.parse::().is_ok();
+ if patch.font.is_some() || size.is_some() {
+ let old = appearance::parse_da(da);
+ let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string());
+ // `parse_da` reads sizes up to 300 points; keep the same bound when writing.
+ let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0));
+ let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i);
+ match at {
+ Some(i) => {
+ if let Some(slot) = toks.get_mut(i - 2) {
+ *slot = format!("/{font}");
+ }
+ if let Some(slot) = toks.get_mut(i - 1) {
+ *slot = size;
+ }
+ }
+ None => toks.extend([format!("/{font}"), size, "Tf".into()]),
+ }
+ }
+ if let Some(c) = patch.text {
+ let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()];
+ let at = toks.iter().enumerate().rev().find_map(|(i, t)| {
+ let n = match t.as_str() {
+ "g" => 1,
+ "rg" => 3,
+ "k" => 4,
+ _ => return None,
+ };
+ let start = i.checked_sub(n)?;
+ toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i))
+ });
+ match at {
+ Some((start, end)) => {
+ toks.splice(start..=end, rgb);
+ }
+ None => toks.extend(rgb),
+ }
+ }
+ toks.join(" ")
+}
+
+fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> {
+ let touches_mk = patch.border.is_some() || patch.fill.is_some();
+ let touches_bs = patch.width.is_some() || patch.style.is_some();
+ // A size- or font-only edit changes /DA, not the widget: leave it out of the save.
+ if !touches_mk && !touches_bs {
+ return Ok(());
+ }
+ let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?;
+ if touches_mk {
+ let indirect = wd.get(b"MK").and_then(Object::as_ref);
+ let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
+ for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] {
+ if let Some(change) = change {
+ match change {
+ Some(c) => mk.set(key.to_vec(), rgb(c)),
+ None => {
+ mk.remove(key);
+ }
+ }
+ }
+ }
+ // A shared indirect /MK is updated where it lives, not copied into each widget.
+ match indirect {
+ Some(r) => doc.set(r, Object::Dict(mk)),
+ None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?,
+ }
+ }
+ if touches_bs {
+ let indirect = wd.get(b"BS").and_then(Object::as_ref);
+ let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default();
+ if let Some(width) = patch.width {
+ bs.set(b"W".to_vec(), Object::Real(width));
+ }
+ if let Some(style) = patch.style {
+ bs.set(b"S".to_vec(), Object::name(style.code()));
+ if style == BorderStyle::Dashed && !bs.contains(b"D") {
+ bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)]));
+ }
+ }
+ match indirect {
+ Some(r) => doc.set(r, Object::Dict(bs)),
+ None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?,
+ }
+ }
+ Ok(())
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BorderStyle {
Solid,
@@ -704,6 +830,15 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result {
+ if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
+ return invalid("font size must be a finite, non-negative number");
+ }
+ if let Some(patch) = &props.appearance {
+ if props.look.is_some() {
+ return invalid("give an appearance patch or a complete look, not both");
+ }
+ patch.validate()?;
+ }
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
// Validated before anything is written, so a refused rotation leaves the document unchanged.
@@ -754,13 +889,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result da_string(l.font, size, l.text),
- None => da_string(current.font, size, current.text),
+ None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()),
};
- if props.look.is_some() {
+ if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) {
ensure_form(doc)?;
}
let mut ff = f.flags;
@@ -855,10 +989,30 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result {}
}
- if props.font_size.is_some() || props.look.is_some() {
+ if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) {
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
}
})?;
+ if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) {
+ let patch = &props.appearance.unwrap_or_default();
+ for w in &f.widgets {
+ patch_widget_look(doc, w, patch)?;
+ // A widget's own /DA overrides its field's /DA. Patch that override separately
+ // rather than replacing its font or colour with the first widget's defaults.
+ if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) {
+ let da = doc
+ .get(w.obj)
+ .as_dict()
+ .and_then(|d| d.get(b"DA").cloned())
+ .map(|v| doc.resolve(&v))
+ .and_then(|v| v.as_string().map(|s| s.to_text()));
+ if let Some(da) = da {
+ let da = patch_da(&da, props.font_size, patch);
+ doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?;
+ }
+ }
+ }
+ }
if let Some(l) = &props.look {
let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect());
for w in &f.widgets {
@@ -970,8 +1124,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Vec<_> {
+ widgets.iter().map(|w| doc.get(w.obj).as_dict().map(|d| (d.get(b"MK").cloned(), d.get(b"BS").cloned()))).collect()
+ };
+ let before = border(&doc);
+ set_props(&mut doc, "size", &FieldProps { font_size: Some(12.0), ..Default::default() }).unwrap();
+ assert_eq!(border(&doc), before, "a size-only edit leaves each widget's /MK and /BS alone");
+ // A border edit updates the shared /MK where it lives instead of copying it into each widget.
+ let patch = LookPatch { border: Some(Some([1.0, 0.0, 0.0])), ..Default::default() };
+ set_props(&mut doc, "size", &FieldProps { appearance: Some(patch), ..Default::default() }).unwrap();
+ for w in &widgets {
+ assert_eq!(doc.get(w.obj).as_dict().unwrap().get(b"MK"), Some(&Object::Ref(shared)), "the widget still points at the shared /MK");
+ }
+ let shared_mk = doc.get(shared).as_dict().cloned().unwrap();
+ assert_eq!(shared_mk.get(b"BC"), Some(&Object::Array(vec![Object::Real(1.0), Object::Real(0.0), Object::Real(0.0)])));
+}
+
+#[test]
+fn partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions() {
+ let mut doc = fixture();
+ let widgets = field(&fields(&doc), "size").widgets.clone();
+ let mut refs = Vec::new();
+ for (i, widget) in widgets.iter().enumerate() {
+ let mut mk = pdfcraft_cos::Dict::new();
+ mk.set(b"BG".to_vec(), Object::Array(vec![Object::Real(i as f64), 0.into(), 1.into()]));
+ mk.set(b"BC".to_vec(), Object::Array(vec![0.into(), Object::Real(i as f64), 0.into()]));
+ mk.set(b"CA".to_vec(), pdfcraft_cos::PdfString::text("l"));
+ mk.set(b"R".to_vec(), Object::Int(90));
+ mk.set(b"VendorKey".to_vec(), Object::Int(71));
+ let mk_ref = doc.add(Object::Dict(mk));
+ let mut bs = pdfcraft_cos::Dict::new();
+ bs.set(b"S".to_vec(), Object::name("D"));
+ bs.set(b"W".to_vec(), Object::Real(1.0));
+ bs.set(b"D".to_vec(), Object::Array(vec![4.into(), 2.into()]));
+ bs.set(b"VendorKey".to_vec(), Object::Int(83));
+ let bs_ref = doc.add(Object::Dict(bs));
+ let da = if i == 0 { "/Helv 8 Tf 0.1 g" } else { "/Cour 19 Tf 0.3 g" };
+ doc.update_dict(widget.obj, |d| {
+ d.set(b"MK".to_vec(), Object::Ref(mk_ref));
+ d.set(b"BS".to_vec(), Object::Ref(bs_ref));
+ d.set(b"DA".to_vec(), pdfcraft_cos::PdfString::text(da));
+ })
+ .unwrap();
+ refs.push(mk_ref);
+ }
+ let patch = LookPatch { width: Some(5.0), ..Default::default() };
+ set_props(&mut doc, "size", &FieldProps { font_size: Some(16.0), appearance: Some(patch), ..Default::default() }).unwrap();
+ for (i, widget) in widgets.iter().enumerate() {
+ let wd = doc.get(widget.obj);
+ let wd = wd.as_dict().unwrap();
+ assert_eq!(wd.reference(b"MK"), Some(refs[i]), "untouched MK stays indirect");
+ let bs = wd.get(b"BS").map(|v| doc.resolve(v)).unwrap();
+ let bs = bs.as_dict().unwrap();
+ assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(5.0));
+ assert_eq!(bs.int(b"VendorKey"), Some(83));
+ assert_eq!(bs.get(b"D").unwrap().as_array().unwrap().len(), 2);
+ let da = wd.get(b"DA").map(|v| doc.resolve(v)).unwrap().as_string().unwrap().to_text();
+ let parsed = appearance::parse_da(&da);
+ assert_eq!(parsed.font, if i == 0 { "Helv" } else { "Cour" });
+ assert_eq!(parsed.color, if i == 0 { "0.1 g" } else { "0.3 g" });
+ assert_eq!(parsed.size, 16.0);
+ }
+ set_props(&mut doc, "size", &FieldProps { appearance: Some(LookPatch { fill: Some(None), ..Default::default() }), ..Default::default() })
+ .unwrap();
+ let doc = reopen(&doc);
+ for widget in &widgets {
+ let mk = doc.get(widget.obj).as_dict().unwrap().get(b"MK").map(|v| doc.resolve(v)).unwrap();
+ let mk = mk.as_dict().unwrap();
+ assert!(!mk.contains(b"BG"));
+ assert!(mk.contains(b"BC") && mk.contains(b"CA"));
+ assert_eq!(mk.int(b"R"), Some(90));
+ assert_eq!(mk.int(b"VendorKey"), Some(71));
+ }
+}
+
+#[test]
+fn invalid_appearance_patches_are_refused_before_anything_is_written() {
+ let mut doc = fixture();
+ let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ for size in [f64::NAN, f64::INFINITY, -1.0] {
+ assert!(
+ set_props(&mut doc, "name", &FieldProps { font_size: Some(size), tooltip: Some("Not applied".into()), ..Default::default() }).is_err()
+ );
+ assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
+ }
+ for patch in [
+ LookPatch { width: Some(f64::NAN), ..Default::default() },
+ LookPatch { width: Some(-1.0), ..Default::default() },
+ LookPatch { fill: Some(Some([f64::INFINITY, 0.0, 0.0])), ..Default::default() },
+ LookPatch { text: Some([1.1, 0.0, 0.0]), ..Default::default() },
+ ] {
+ assert!(
+ set_props(&mut doc, "name", &FieldProps { appearance: Some(patch), tooltip: Some("Not applied".into()), ..Default::default() }).is_err()
+ );
+ assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
+ }
+ let props = FieldProps {
+ appearance: Some(LookPatch { width: Some(2.0), ..Default::default() }),
+ look: Some(look(&doc, &field(&fields(&doc), "name").clone())),
+ ..Default::default()
+ };
+ assert!(set_props(&mut doc, "name", &props).is_err());
+ assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
+}
diff --git a/crates/ui-egui/src/bulk_fields.rs b/crates/ui-egui/src/bulk_fields.rs
new file mode 100644
index 000000000..480ace703
--- /dev/null
+++ b/crates/ui-egui/src/bulk_fields.rs
@@ -0,0 +1,379 @@
+//! Shared Field Properties for Prepare a form. Each property is opt-in, and appearance
+//! changes are merged per field so a mixed selection keeps everything the user did not edit.
+
+use pdfcraft_engine::{BorderStyle, CheckStyle, DocId, Edit, FieldFont, FieldLookPatch, FieldProps, FormFieldKind, field_flags};
+
+use crate::{prepare, theme, widgets};
+
+/// One property shown by the dialog. `apply` is explicit: merely opening the dialog, or
+/// visiting a tab, must never turn mixed values into the first field's value.
+#[derive(Clone, Debug)]
+pub struct Change {
+ pub value: T,
+ pub mixed: bool,
+ pub apply: bool,
+}
+
+impl Change {
+ fn from_values(values: impl IntoIterator- ) -> Option
{
+ let mut values = values.into_iter();
+ let value = values.next()?;
+ let mixed = values.any(|other| other != value);
+ Some(Self { value, mixed, apply: false })
+ }
+
+ fn changed(&self, current: &T) -> Option {
+ (self.apply && current != &self.value).then(|| self.value.clone())
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Tab {
+ General,
+ Appearance,
+ Options,
+}
+
+/// A dialog is bound to its source document, not whichever tab happens to be active later.
+#[derive(Clone, Debug)]
+pub struct Draft {
+ pub doc: DocId,
+ pub names: Vec,
+ pub tab: Tab,
+ pub tooltip: Change,
+ pub read_only: Change,
+ pub required: Change,
+ pub locked: Change,
+ pub font_size: Change,
+ pub font: Change,
+ /// The original resource can be a custom font outside the three built-in choices.
+ pub font_label: String,
+ pub text: Change<[f64; 3]>,
+ pub border: Change>,
+ pub fill: Change >,
+ pub width: Change,
+ pub style: Change,
+ pub align: Change,
+ pub multiline: Change,
+ /// Zero means no limit, as in the headless form tool.
+ pub max_length: Change,
+ pub check_style: Change,
+ /// A flag, its label, whether the UI wording is inverted, and its proposed value.
+ pub flags: Vec<(u32, &'static str, bool, Change)>,
+ pub error: Option,
+ text_fields: bool,
+ choice_fields: bool,
+ check_fields: bool,
+ text_appearance: bool,
+}
+
+impl Draft {
+ /// The draft for editing `names` together, or why they can't be (a message for `tl!`).
+ pub fn new(doc: &pdfcraft_engine::Document, names: Vec) -> Result {
+ const MISSING: &str = "The selected fields could not be found. Select them again.";
+ let fields = names.iter().map(|name| doc.form.iter().find(|f| &f.name == name)).collect::>>().ok_or(MISSING)?;
+ let looks = names
+ .iter()
+ .map(|name| doc.field_look(name))
+ .collect:: >>()
+ .ok_or("A selected field has no widget on any page, so the fields can't be edited together.")?;
+ if fields.len() < 2 {
+ return Err(MISSING);
+ }
+ Self::build(doc, names, &fields, &looks).ok_or(MISSING)
+ }
+
+ fn build(
+ doc: &pdfcraft_engine::Document,
+ names: Vec,
+ fields: &[&pdfcraft_engine::FormField],
+ looks: &[pdfcraft_engine::FieldLook],
+ ) -> Option {
+ let text_fields = fields.iter().all(|f| f.kind == FormFieldKind::Text);
+ let choice_fields = fields.iter().all(|f| matches!(f.kind, FormFieldKind::Combo | FormFieldKind::List));
+ let check_fields = fields.iter().all(|f| matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio));
+ let text_appearance =
+ fields.iter().all(|f| matches!(f.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List | FormFieldKind::PushButton));
+ let mut flag_defs = Vec::new();
+ if text_fields {
+ flag_defs.extend([
+ (field_flags::DO_NOT_SCROLL, "Scroll long text", true),
+ (field_flags::DO_NOT_SPELL_CHECK, "Check spelling", true),
+ (field_flags::PASSWORD, "Password", false),
+ (field_flags::COMB, "Comb of characters", false),
+ (field_flags::FILE_SELECT, "Field is used for file selection", false),
+ ]);
+ } else if choice_fields {
+ flag_defs
+ .extend([(field_flags::SORT, "Sort items", false), (field_flags::COMMIT_ON_SEL_CHANGE, "Commit selected value immediately", false)]);
+ if fields.iter().all(|f| f.kind == FormFieldKind::Combo) {
+ flag_defs.push((field_flags::EDIT, "Allow user to enter custom text", false));
+ } else if fields.iter().all(|f| f.kind == FormFieldKind::List) {
+ flag_defs.push((field_flags::MULTI_SELECT, "Multiple selection", false));
+ }
+ }
+ let flags = flag_defs
+ .into_iter()
+ .map(|(bit, label, inverted)| Change::from_values(fields.iter().map(|f| f.has(bit) != inverted)).map(|c| (bit, label, inverted, c)))
+ .collect::>>()?;
+ let mut font = Change::from_values(looks.iter().map(|l| l.font))?;
+ let resource = prepare::da_font(&fields.first()?.da);
+ font.mixed |= fields.iter().any(|f| prepare::da_font(&f.da) != resource);
+ let font_label = if font.mixed {
+ "Mixed".to_string()
+ } else if ["Helv", "Helvetica", "TiRo", "Times-Roman", "Cour", "Courier"].contains(&resource) {
+ font.value.label().to_string()
+ } else {
+ "Custom font".to_string()
+ };
+ Some(Self {
+ doc: doc.id,
+ names,
+ tab: Tab::General,
+ tooltip: Change::from_values(fields.iter().map(|f| f.tooltip.clone().unwrap_or_default()))?,
+ read_only: Change::from_values(fields.iter().map(|f| f.read_only()))?,
+ required: Change::from_values(fields.iter().map(|f| f.has(field_flags::REQUIRED)))?,
+ locked: Change::from_values(fields.iter().map(|f| f.locked()))?,
+ font_size: Change::from_values(fields.iter().map(|f| prepare::da_size(&f.da)))?,
+ font,
+ font_label,
+ text: Change::from_values(looks.iter().map(|l| l.text))?,
+ border: Change::from_values(looks.iter().map(|l| l.border))?,
+ fill: Change::from_values(looks.iter().map(|l| l.fill))?,
+ width: Change::from_values(looks.iter().map(|l| l.width))?,
+ style: Change::from_values(looks.iter().map(|l| l.style))?,
+ align: Change::from_values(fields.iter().map(|f| f.quadding))?,
+ multiline: Change::from_values(fields.iter().map(|f| f.has(field_flags::MULTILINE)))?,
+ max_length: Change::from_values(fields.iter().map(|f| f.max_len.unwrap_or(0)))?,
+ check_style: Change::from_values(fields.iter().map(|f| doc.field_check_style(&f.name).unwrap_or(CheckStyle::Check)))?,
+ flags,
+ error: None,
+ text_fields,
+ choice_fields,
+ check_fields,
+ text_appearance,
+ })
+ }
+
+ /// Resolve only the chosen changes against the current document. This also preserves
+ /// unrelated appearance changes made since the dialog opened (e.g. through UI control).
+ pub fn edits(&self, doc: &pdfcraft_engine::Document) -> Result, String> {
+ if doc.id != self.doc {
+ return Err("The document changed while you were editing field properties.".into());
+ }
+ let mut edits = Vec::new();
+ for name in &self.names {
+ let f = doc
+ .form
+ .iter()
+ .find(|f| &f.name == name)
+ .ok_or_else(|| crate::i18n::fmt(crate::i18n::t("The field {name} no longer exists."), &[("name", name)]))?;
+ let patch = FieldLookPatch {
+ border: self.border.apply.then_some(self.border.value),
+ fill: self.fill.apply.then_some(self.fill.value),
+ width: self.width.apply.then_some(self.width.value),
+ style: self.style.apply.then_some(self.style.value),
+ text: (self.text_appearance && self.text.apply).then_some(self.text.value),
+ font: (self.text_appearance && self.font.apply).then_some(self.font.value),
+ };
+ let props = FieldProps {
+ tooltip: self.tooltip.changed(&f.tooltip.clone().unwrap_or_default()),
+ read_only: self.read_only.changed(&f.read_only()),
+ required: self.required.changed(&f.has(field_flags::REQUIRED)),
+ locked: self.locked.changed(&f.locked()),
+ appearance: (patch != FieldLookPatch::default()).then_some(patch),
+ font_size: (self.text_appearance && self.font_size.apply).then_some(self.font_size.value),
+ multiline: self.text_fields.then(|| self.multiline.changed(&f.has(field_flags::MULTILINE))).flatten(),
+ max_len: self.text_fields.then(|| self.max_length.changed(&f.max_len.unwrap_or(0))).flatten().map(|v| (v > 0).then_some(v)),
+ quadding: (self.text_fields || self.choice_fields).then(|| self.align.changed(&f.quadding)).flatten(),
+ check_style: self.check_fields.then(|| self.check_style.changed(&doc.field_check_style(name).unwrap_or(CheckStyle::Check))).flatten(),
+ flags: self
+ .flags
+ .iter()
+ .filter_map(|(bit, _, inverted, c)| c.changed(&(f.has(*bit) != *inverted)).map(|v| (*bit, v != *inverted)))
+ .collect(),
+ ..Default::default()
+ };
+ if props != FieldProps::default() {
+ edits.push(Edit::SetFieldProps { name: name.clone(), props: Box::new(props) });
+ }
+ }
+ Ok(edits)
+ }
+}
+
+fn row(ui: &mut egui::Ui, label: &str, c: &mut Change, edit: impl FnOnce(&mut egui::Ui, &mut T)) {
+ ui.checkbox(&mut c.apply, tl!(label));
+ // Colour swatches contain their own grid; give every property's editor a separate
+ // namespace so border and fill controls do not share widget identities.
+ ui.push_id(label, |ui| ui.add_enabled_ui(c.apply, |ui| edit(ui, &mut c.value)));
+ ui.label(if c.mixed { tl!("Mixed") } else { "" });
+ ui.end_row();
+}
+
+impl crate::PdfCraftApp {
+ pub(crate) fn apply_bulk_field_props(&mut self) -> bool {
+ let result = self.bulk_field_props.as_ref().ok_or("The field properties dialog is no longer open.".to_string()).and_then(|d| {
+ if self.active_ids().map(|(_, id)| id) != Some(d.doc) {
+ return Err("The document changed while you were editing field properties.".into());
+ }
+ self.session.get(d.doc).ok_or("The document is no longer open.".to_string()).and_then(|doc| d.edits(doc))
+ });
+ let error = match result {
+ Ok(edits) if edits.is_empty() => return true,
+ Ok(edits) => {
+ if self.apply_edit(Edit::Batch { label: "Change field properties".into(), edits }) {
+ return true;
+ }
+ self.toast.as_ref().map(|(message, _)| message.clone()).unwrap_or_else(|| "The field properties could not be changed.".into())
+ }
+ Err(error) => error,
+ };
+ if let Some(draft) = self.bulk_field_props.as_mut() {
+ draft.error = Some(error);
+ }
+ false
+ }
+}
+
+fn bool_row(ui: &mut egui::Ui, label: &str, c: &mut Change) {
+ row(ui, label, c, |ui, value| {
+ egui::ComboBox::from_id_salt(label).selected_text(tl!(if *value { "Yes" } else { "No" })).show_ui(ui, |ui| {
+ ui.selectable_value(value, true, tl!("Yes"));
+ ui.selectable_value(value, false, tl!("No"));
+ });
+ });
+}
+
+fn color(ui: &mut egui::Ui, value: &mut Option<[f64; 3]>) {
+ ui.horizontal(|ui| {
+ let mut none = value.is_none();
+ if ui.checkbox(&mut none, tl!("No color")).changed() {
+ *value = if none { None } else { Some([0.0; 3]) };
+ }
+ if let Some(c) = crate::comments::swatch_grid(ui, *value) {
+ *value = Some(c);
+ }
+ });
+}
+
+/// Check a property to apply its value to every selected field. No unchecked value is used.
+pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool, bool) {
+ ui.set_width(600.0);
+ ui.label(egui::RichText::new(tl!("Shared Field Properties")).font(theme::semibold(18.0)));
+ ui.label(crate::i18n::fmt(tl!("{n} fields selected"), &[("n", &d.names.len().to_string())]));
+ ui.label(
+ egui::RichText::new(tl!("Only checked properties will change. Mixed means the selected fields have different values."))
+ .small()
+ .color(t.text_muted),
+ );
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ for (tab, label) in [(Tab::General, "General"), (Tab::Appearance, "Appearance"), (Tab::Options, "Options")] {
+ if tab == Tab::Options && !(d.text_fields || d.choice_fields || d.check_fields) {
+ continue;
+ }
+ if widgets::mode_tab(ui, tl!(label), d.tab == tab).clicked() {
+ d.tab = tab;
+ }
+ }
+ });
+ ui.separator();
+ egui::ScrollArea::vertical().max_height(430.0).auto_shrink([false, true]).show(ui, |ui| {
+ egui::Grid::new("bulk-field-properties").num_columns(3).spacing([16.0, 10.0]).show(ui, |ui| match d.tab {
+ Tab::General => {
+ row(ui, "Tooltip:", &mut d.tooltip, |ui, v| {
+ ui.add(egui::TextEdit::singleline(v).desired_width(280.0));
+ });
+ bool_row(ui, "Read Only", &mut d.read_only);
+ bool_row(ui, "Required", &mut d.required);
+ bool_row(ui, "Locked", &mut d.locked);
+ }
+ Tab::Appearance => {
+ if d.text_appearance {
+ row(ui, "Font Size:", &mut d.font_size, |ui, v| {
+ ui.add(egui::DragValue::new(v).range(0.0..=100.0).speed(0.25).suffix(" pt"));
+ });
+ let font_label = if d.font.apply { d.font.value.label().to_string() } else { d.font_label.clone() };
+ row(ui, "Font:", &mut d.font, |ui, v| {
+ egui::ComboBox::from_id_salt("bulk-font").selected_text(tl!(&font_label)).show_ui(ui, |ui| {
+ for font in FieldFont::ALL {
+ ui.selectable_value(v, font, font.label());
+ }
+ });
+ });
+ row(ui, "Text Color:", &mut d.text, |ui, v| {
+ if let Some(c) = crate::comments::swatch_grid(ui, Some(*v)) {
+ *v = c;
+ }
+ });
+ }
+ row(ui, "Border Color:", &mut d.border, color);
+ row(ui, "Fill Color:", &mut d.fill, color);
+ row(ui, "Line Thickness:", &mut d.width, |ui, v| {
+ ui.add(egui::DragValue::new(v).range(0.0..=12.0).speed(0.25).suffix(" pt"));
+ });
+ row(ui, "Line Style:", &mut d.style, |ui, v| {
+ egui::ComboBox::from_id_salt("bulk-style").selected_text(tl!(v.label())).show_ui(ui, |ui| {
+ for style in BorderStyle::ALL {
+ ui.selectable_value(v, style, tl!(style.label()));
+ }
+ });
+ });
+ }
+ Tab::Options => {
+ if d.text_fields || d.choice_fields {
+ row(ui, "Alignment:", &mut d.align, |ui, v| {
+ let label = match *v {
+ 1 => "Center",
+ 2 => "Right",
+ _ => "Left",
+ };
+ egui::ComboBox::from_id_salt("bulk-align").selected_text(tl!(label)).show_ui(ui, |ui| {
+ for (value, label) in [(0, "Left"), (1, "Center"), (2, "Right")] {
+ ui.selectable_value(v, value, tl!(label));
+ }
+ });
+ });
+ }
+ if d.text_fields {
+ bool_row(ui, "Multi-line", &mut d.multiline);
+ row(ui, "Character limit:", &mut d.max_length, |ui, v| {
+ ui.add(egui::DragValue::new(v).range(0..=1_000_000));
+ });
+ }
+ if d.check_fields {
+ row(ui, "Button Style:", &mut d.check_style, |ui, v| {
+ egui::ComboBox::from_id_salt("bulk-check-style").selected_text(tl!(v.label())).show_ui(ui, |ui| {
+ for style in CheckStyle::ALL {
+ ui.selectable_value(v, style, tl!(style.label()));
+ }
+ });
+ });
+ }
+ for (_, label, _, change) in &mut d.flags {
+ bool_row(ui, label, change);
+ }
+ }
+ });
+ ui.add_space(8.0);
+ if d.tab == Tab::Appearance && d.text_appearance {
+ ui.label(egui::RichText::new(tl!("Font size 0 uses automatic sizing.")).small().color(t.text_muted));
+ } else if d.tab == Tab::Options && d.text_fields {
+ ui.label(egui::RichText::new(tl!("Character limit 0 means no limit. A comb of characters needs a limit.")).small().color(t.text_muted));
+ } else if d.tab == Tab::General {
+ ui.label(egui::RichText::new(tl!("To change locked fields, also set Locked to No.")).small().color(t.text_muted));
+ }
+ });
+ if let Some(error) = &d.error {
+ ui.add_space(8.0);
+ ui.label(egui::RichText::new(tl!(error)).color(t.text));
+ }
+ ui.separator();
+ let (mut apply, mut cancel) = (false, false);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ apply = widgets::pill_button(ui, tl!("OK"), true).clicked();
+ cancel = widgets::pill_button(ui, tl!("Cancel"), false).clicked();
+ });
+ (apply, cancel)
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index e026e62c0..edec6d02f 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -43,6 +43,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
let mut export_now = false;
let mut props_now = false;
let mut field_props_now = false;
+ let mut bulk_field_props_now = false;
let mut redact_now: Option = None;
let mut print_go = false;
let mut revert_now = false;
@@ -64,7 +65,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
ui.set_width(match dialog {
Dialog::Properties(_) => 640.0,
Dialog::Print => 820.0,
- Dialog::FieldProps => 600.0,
+ Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::About => 780.0,
_ => 520.0,
});
@@ -817,6 +818,16 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
close = apply || cancel;
return;
}
+ Dialog::BulkFieldProps => {
+ let Some(d) = app.bulk_field_props.as_mut() else {
+ close = true;
+ return;
+ };
+ let (apply, cancel) = crate::bulk_fields::body(ui, d, &t);
+ bulk_field_props_now = apply;
+ close = cancel;
+ return;
+ }
Dialog::CommentProps => {
let (apply, cancel) = crate::comment_props::body(ui, app, &t);
props_now = apply;
@@ -1218,6 +1229,9 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
}
_ => {}
}
+ if bulk_field_props_now {
+ close = app.apply_bulk_field_props();
+ }
if field_props_now
&& let Some(d) = app.field_props.take()
&& let Some(props) = d.props()
@@ -1273,6 +1287,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
app.dialog = None;
app.props_draft = None;
app.view_draft = None;
+ app.bulk_field_props = None;
} else {
app.dialog = Some(next);
}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index dc1851011..f17eac81a 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2075,3 +2075,23 @@ action wizard Start Старт
This printer offers no settings of its own. Този принтер няма собствени настройки.
Scroll tabs left Превърти разделите наляво
Scroll tabs right Превърти разделите надясно
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Общи свойства на полетата
+ {n} fields selected Избрани полета: {n}
+ Mixed Различни стойности
+ Custom font Потребителски шрифт
+ Only checked properties will change. Mixed means the selected fields have different values. Ще се променят само отметнатите свойства. „Различни стойности“ означава, че избраните полета имат различни стойности.
+ Shift- or Ctrl/Command-click to select several fields. Щракнете с Shift или Ctrl/Command, за да изберете няколко полета.
+ Font size 0 uses automatic sizing. Размер на шрифта 0 включва автоматично оразмеряване.
+ Character limit: Ограничение на знаците:
+ Character limit 0 means no limit. A comb of characters needs a limit. Ограничение 0 означава без ограничение. Клетките за знаци изискват ограничение.
+ To change locked fields, also set Locked to No. За да промените заключени полета, задайте и „Заключено“ на „Не“.
+ Comb of characters Гребен от знаци
+ The document changed while you were editing field properties. Документът се промени, докато редактирахте свойствата на полетата.
+ The document is no longer open. Документът вече не е отворен.
+ The field properties dialog is no longer open. Прозорецът със свойства на полетата вече не е отворен.
+ The field properties could not be changed. Свойствата на полетата не можаха да бъдат променени.
+ The selected fields could not be found. Select them again. Избраните полета не бяха намерени. Изберете ги отново.
+ The field {name} no longer exists. Полето {name} вече не съществува.
+ A selected field has no widget on any page, so the fields can't be edited together. Едно от избраните полета няма елемент на нито една страница, затова полетата не могат да се редактират заедно.
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 5ce51404c..811894cc5 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -127,3 +127,15 @@
Slash Lomítko
Scroll tabs left Posunout karty doleva
Scroll tabs right Posunout karty doprava
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Společné vlastnosti polí
+ {n} fields selected Vybráno polí: {n}
+ Mixed Různé hodnoty
+ Custom font Vlastní písmo
+ Only checked properties will change. Mixed means the selected fields have different values. Změní se pouze zaškrtnuté vlastnosti. „Různé hodnoty“ znamená, že vybraná pole mají odlišné hodnoty.
+ Shift- or Ctrl/Command-click to select several fields. Kliknutím se Shift nebo Ctrl/Command vyberete více polí.
+ Font size 0 uses automatic sizing. Velikost písma 0 zapne automatickou velikost.
+ Character limit: Limit znaků:
+ Character limit 0 means no limit. A comb of characters needs a limit. Limit znaků 0 znamená bez omezení. Rozdělení na znaky vyžaduje limit.
+ To change locked fields, also set Locked to No. Chcete-li změnit zamčená pole, nastavte také „Zamčeno“ na „Ne“.
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 8b35a4fd8..e0f89dff4 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2113,3 +2113,23 @@ action wizard Start Starten
This printer offers no settings of its own. Dieser Drucker bietet keine eigenen Einstellungen.
Scroll tabs left Tabs nach links scrollen
Scroll tabs right Tabs nach rechts scrollen
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Gemeinsame Feldeigenschaften
+ {n} fields selected {n} Felder ausgewählt
+ Mixed Gemischt
+ Custom font Benutzerdefinierte Schrift
+ Only checked properties will change. Mixed means the selected fields have different values. Nur angehakte Eigenschaften werden geändert. „Gemischt“ bedeutet, dass die ausgewählten Felder unterschiedliche Werte haben.
+ Shift- or Ctrl/Command-click to select several fields. Mit Umschalt- oder Strg/Befehl-Klick mehrere Felder auswählen.
+ Font size 0 uses automatic sizing. Schriftgröße 0 passt die Größe automatisch an.
+ Character limit: Zeichenlimit:
+ Character limit 0 means no limit. A comb of characters needs a limit. Zeichenlimit 0 bedeutet unbegrenzt. Ein Zeichenraster benötigt ein Limit.
+ To change locked fields, also set Locked to No. Um gesperrte Felder zu ändern, setze auch „Gesperrt“ auf „Nein“.
+ Comb of characters Kammfeld
+ The document changed while you were editing field properties. Das Dokument hat sich geändert, während du die Feldeigenschaften bearbeitet hast.
+ The document is no longer open. Das Dokument ist nicht mehr geöffnet.
+ The field properties dialog is no longer open. Der Dialog „Feldeigenschaften“ ist nicht mehr geöffnet.
+ The field properties could not be changed. Die Feldeigenschaften konnten nicht geändert werden.
+ The selected fields could not be found. Select them again. Die ausgewählten Felder wurden nicht gefunden. Wähle sie erneut aus.
+ The field {name} no longer exists. Das Feld {name} existiert nicht mehr.
+ A selected field has no widget on any page, so the fields can't be edited together. Ein ausgewähltes Feld hat auf keiner Seite ein Widget, daher können die Felder nicht gemeinsam bearbeitet werden.
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 6f4c7e9a7..011837c0d 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2029,3 +2029,23 @@
This printer offers no settings of its own. Esta impresora no ofrece ajustes propios.
Scroll tabs left Desplazar las pestañas a la izquierda
Scroll tabs right Desplazar las pestañas a la derecha
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Propiedades comunes de los campos
+ {n} fields selected {n} campos seleccionados
+ Mixed Valores distintos
+ Custom font Fuente personalizada
+ Only checked properties will change. Mixed means the selected fields have different values. Solo se cambiarán las propiedades marcadas. «Valores distintos» indica que los campos seleccionados tienen valores diferentes.
+ Shift- or Ctrl/Command-click to select several fields. Haz clic con Mayús o Ctrl/Comando para seleccionar varios campos.
+ Font size 0 uses automatic sizing. El tamaño de fuente 0 ajusta el tamaño automáticamente.
+ Character limit: Límite de caracteres:
+ Character limit 0 means no limit. A comb of characters needs a limit. El límite de caracteres 0 significa sin límite. Las casillas de caracteres necesitan un límite.
+ To change locked fields, also set Locked to No. Para cambiar campos bloqueados, establece también «Bloqueado» en «No».
+ Comb of characters Peine de caracteres
+ The document changed while you were editing field properties. El documento cambió mientras editabas las propiedades de los campos.
+ The document is no longer open. El documento ya no está abierto.
+ The field properties dialog is no longer open. El cuadro de propiedades de los campos ya no está abierto.
+ The field properties could not be changed. No se pudieron cambiar las propiedades de los campos.
+ The selected fields could not be found. Select them again. No se encontraron los campos seleccionados. Vuelve a seleccionarlos.
+ The field {name} no longer exists. El campo {name} ya no existe.
+ A selected field has no widget on any page, so the fields can't be edited together. Un campo seleccionado no tiene ningún widget en ninguna página, así que los campos no se pueden editar juntos.
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 80e0dc1b5..81c24252d 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2113,3 +2113,23 @@ comment menu Edit Modifier
signature pad Type Saisir
Scroll tabs left Faire défiler les onglets vers la gauche
Scroll tabs right Faire défiler les onglets vers la droite
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Propriétés communes des champs
+ {n} fields selected {n} champs sélectionnés
+ Mixed Valeurs différentes
+ Custom font Police personnalisée
+ Only checked properties will change. Mixed means the selected fields have different values. Seules les propriétés cochées seront modifiées. « Valeurs différentes » indique que les champs sélectionnés ont des valeurs différentes.
+ Shift- or Ctrl/Command-click to select several fields. Cliquez avec Maj ou Ctrl/Commande pour sélectionner plusieurs champs.
+ Font size 0 uses automatic sizing. La taille de police 0 active le dimensionnement automatique.
+ Character limit: Limite de caractères :
+ Character limit 0 means no limit. A comb of characters needs a limit. Une limite de caractères de 0 signifie aucune limite. Les cases de caractères nécessitent une limite.
+ To change locked fields, also set Locked to No. Pour modifier les champs verrouillés, réglez aussi « Verrouillé » sur « Non ».
+ Comb of characters Peigne de caractères
+ The document changed while you were editing field properties. Le document a changé pendant que vous modifiiez les propriétés des champs.
+ The document is no longer open. Le document n'est plus ouvert.
+ The field properties dialog is no longer open. La boîte de dialogue des propriétés des champs n'est plus ouverte.
+ The field properties could not be changed. Les propriétés des champs n'ont pas pu être modifiées.
+ The selected fields could not be found. Select them again. Les champs sélectionnés sont introuvables. Sélectionnez-les de nouveau.
+ The field {name} no longer exists. Le champ {name} n'existe plus.
+ A selected field has no widget on any page, so the fields can't be edited together. Un champ sélectionné n'a de widget sur aucune page : les champs ne peuvent pas être modifiés ensemble.
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 171878e45..c69943d6e 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2111,3 +2111,23 @@ action wizard Start Indítás
This printer offers no settings of its own. Ennek a nyomtatónak nincsenek saját beállításai.
Scroll tabs left Lapok görgetése balra
Scroll tabs right Lapok görgetése jobbra
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Közös mezőtulajdonságok
+ {n} fields selected {n} mező kijelölve
+ Mixed Eltérő értékek
+ Custom font Egyéni betűtípus
+ Only checked properties will change. Mixed means the selected fields have different values. Csak a bejelölt tulajdonságok változnak. Az „Eltérő értékek” azt jelenti, hogy a kijelölt mezők értékei különböznek.
+ Shift- or Ctrl/Command-click to select several fields. Több mező kijelöléséhez kattints a Shift vagy Ctrl/Command nyomva tartásával.
+ Font size 0 uses automatic sizing. A 0 betűméret automatikus méretezést jelent.
+ Character limit: Karakterkorlát:
+ Character limit 0 means no limit. A comb of characters needs a limit. A 0 karakterkorlát korlátlan hosszt jelent. A karaktercellákhoz korlát szükséges.
+ To change locked fields, also set Locked to No. Zárolt mezők módosításához a „Zárolt” értékét is állítsd „Nem”-re.
+ Comb of characters Karakterfésű
+ The document changed while you were editing field properties. A dokumentum megváltozott, miközben a mezőtulajdonságokat szerkesztetted.
+ The document is no longer open. A dokumentum már nincs megnyitva.
+ The field properties dialog is no longer open. A mezőtulajdonságok párbeszédablak már nincs megnyitva.
+ The field properties could not be changed. A mezőtulajdonságokat nem sikerült módosítani.
+ The selected fields could not be found. Select them again. A kijelölt mezők nem találhatók. Jelöld ki őket újra.
+ The field {name} no longer exists. A(z) {name} mező már nem létezik.
+ A selected field has no widget on any page, so the fields can't be edited together. Az egyik kijelölt mezőnek egyik oldalon sincs widgetje, ezért a mezők nem szerkeszthetők együtt.
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index ff8935479..cd410703d 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2122,3 +2122,23 @@ action wizard Start Avvia
certificate Subject Titolare
comment menu Edit Modifica
signature pad Type Digita
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Proprietà comuni dei campi
+ {n} fields selected {n} campi selezionati
+ Mixed Valori diversi
+ Custom font Font personalizzato
+ Only checked properties will change. Mixed means the selected fields have different values. Verranno modificate solo le proprietà selezionate. “Valori diversi” indica che i campi selezionati hanno valori differenti.
+ Shift- or Ctrl/Command-click to select several fields. Fai clic con Maiusc o Ctrl/Command per selezionare più campi.
+ Font size 0 uses automatic sizing. La dimensione del font 0 usa il ridimensionamento automatico.
+ Character limit: Limite di caratteri:
+ Character limit 0 means no limit. A comb of characters needs a limit. Un limite di caratteri pari a 0 significa nessun limite. I caratteri in caselle separate richiedono un limite.
+ To change locked fields, also set Locked to No. Per modificare i campi bloccati, imposta anche “Bloccato” su “No”.
+ Comb of characters Pettine di caratteri
+ The document changed while you were editing field properties. Il documento è cambiato mentre modificavi le proprietà dei campi.
+ The document is no longer open. Il documento non è più aperto.
+ The field properties dialog is no longer open. La finestra delle proprietà dei campi non è più aperta.
+ The field properties could not be changed. Impossibile modificare le proprietà dei campi.
+ The selected fields could not be found. Select them again. I campi selezionati non sono stati trovati. Selezionali di nuovo.
+ The field {name} no longer exists. Il campo {name} non esiste più.
+ A selected field has no widget on any page, so the fields can't be edited together. Un campo selezionato non ha widget in nessuna pagina, quindi i campi non possono essere modificati insieme.
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index aaddeea29..aaf1138f9 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2113,3 +2113,23 @@ signature pad Type 入力
Text, marks and signatures become part of the page. Other comments stay editable. テキスト、マーク、署名がページの一部になります。その他のコメントは引き続き編集できます。
Scroll tabs left タブを左にスクロール
Scroll tabs right タブを右にスクロール
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties 共通のフィールドプロパティ
+ {n} fields selected {n} 個のフィールドを選択中
+ Mixed 混在
+ Custom font カスタムフォント
+ Only checked properties will change. Mixed means the selected fields have different values. チェックしたプロパティのみ変更します。「混在」は選択したフィールドの値が異なることを示します。
+ Shift- or Ctrl/Command-click to select several fields. Shift または Ctrl/Command を押しながらクリックすると複数のフィールドを選択できます。
+ Font size 0 uses automatic sizing. フォントサイズが 0 の場合は自動調整します。
+ Character limit: 文字数の上限:
+ Character limit 0 means no limit. A comb of characters needs a limit. 文字数の上限が 0 の場合は無制限です。文字の分割には上限が必要です。
+ To change locked fields, also set Locked to No. ロックされたフィールドを変更するには「ロック」を「いいえ」に設定してください。
+ Comb of characters 文字のマス目
+ The document changed while you were editing field properties. フィールドのプロパティを編集中に文書が変更されました。
+ The document is no longer open. 文書はもう開かれていません。
+ The field properties dialog is no longer open. フィールドのプロパティのダイアログはもう開かれていません。
+ The field properties could not be changed. フィールドのプロパティを変更できませんでした。
+ The selected fields could not be found. Select them again. 選択したフィールドが見つかりません。もう一度選択してください。
+ The field {name} no longer exists. フィールド {name} はもう存在しません。
+ A selected field has no widget on any page, so the fields can't be edited together. 選択したフィールドの一つがどのページにもウィジェットを持たないため、まとめて編集できません。
diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs
index 5c3422ba8..1a473ecd0 100644
--- a/crates/ui-egui/src/i18n/mod.rs
+++ b/crates/ui-egui/src/i18n/mod.rs
@@ -673,6 +673,49 @@ mod tests {
}
/// Every `tl!("…")` literal in the UI source (test modules aside), unescaped.
+ /// Every label Shared Field Properties shows, including those it passes through a variable
+ /// (flag labels, errors, the font label), exists in each full catalog.
+ #[test]
+ fn bulk_field_properties_labels_are_in_every_full_catalog() {
+ let source = include_str!("../bulk_fields.rs");
+ // The string literal starting right after `at`, unescaped.
+ fn literal(rest: &str) -> Option {
+ let mut out = String::new();
+ let mut chars = rest.chars();
+ while let Some(c) = chars.next() {
+ match c {
+ '"' => return Some(out),
+ '\\' => out.push(chars.next()?),
+ c => out.push(c),
+ }
+ }
+ None
+ }
+ let mut labels = std::collections::BTreeSet::new();
+ for marker in ["tl!(\"", "Err(\"", "ok_or(\"", "|| \"", "MISSING: &str = \"", "i18n::t(\"", "_ => \""] {
+ for (at, _) in source.match_indices(marker) {
+ labels.extend(source.get(at + marker.len()..).and_then(literal));
+ }
+ }
+ // Flag labels: `(field_flags::NAME, "Label", inverted)`.
+ for (at, _) in source.match_indices("(field_flags::") {
+ let line = source.get(at..).and_then(|s| s.lines().next()).unwrap_or_default();
+ if let Some(q) = line.find(", \"") {
+ labels.extend(line.get(q + 3..).and_then(literal));
+ }
+ }
+ labels.retain(|l| l.chars().any(char::is_alphabetic) && !l.contains("credits") && !l.starts_with("bulk"));
+ for label in ["Mixed", "Custom font", "The document is no longer open."] {
+ labels.insert(label.to_string());
+ }
+ assert!(labels.len() > 20 && labels.contains("Comb of characters"), "the scan found the dialog's labels: {labels:?}");
+ for code in ["bg", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "te", "uk", "zh-hans", "zh-hant"] {
+ let lang = Lang::from_code(code).expect("registered");
+ let missing: Vec<_> = labels.iter().filter(|l| !has(lang, l)).collect();
+ assert!(missing.is_empty(), "{code} lacks Shared Field Properties labels: {missing:#?}");
+ }
+ }
+
fn ui_literals() -> std::collections::BTreeSet {
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index bb047114f..ef6780814 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2114,3 +2114,23 @@ action wizard Start Iniciar
−Bin −Bin
−LOC −LOC
comment menu Edit Editar
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Propriedades comuns dos campos
+ {n} fields selected {n} campos selecionados
+ Mixed Valores diferentes
+ Custom font Fonte personalizada
+ Only checked properties will change. Mixed means the selected fields have different values. Somente as propriedades marcadas serão alteradas. “Valores diferentes” indica que os campos selecionados têm valores distintos.
+ Shift- or Ctrl/Command-click to select several fields. Clique com Shift ou Ctrl/Command para selecionar vários campos.
+ Font size 0 uses automatic sizing. O tamanho de fonte 0 ativa o ajuste automático.
+ Character limit: Limite de caracteres:
+ Character limit 0 means no limit. A comb of characters needs a limit. O limite de caracteres 0 significa sem limite. As caixas de caracteres precisam de um limite.
+ To change locked fields, also set Locked to No. Para alterar campos bloqueados, defina também “Bloqueado” como “Não”.
+ Comb of characters Pente de caracteres
+ The document changed while you were editing field properties. O documento mudou enquanto você editava as propriedades dos campos.
+ The document is no longer open. O documento não está mais aberto.
+ The field properties dialog is no longer open. A caixa de propriedades dos campos não está mais aberta.
+ The field properties could not be changed. Não foi possível alterar as propriedades dos campos.
+ The selected fields could not be found. Select them again. Os campos selecionados não foram encontrados. Selecione-os novamente.
+ The field {name} no longer exists. O campo {name} não existe mais.
+ A selected field has no widget on any page, so the fields can't be edited together. Um campo selecionado não tem widget em nenhuma página, então os campos não podem ser editados juntos.
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 76154df57..5f9447f41 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2026,3 +2026,23 @@
This printer offers no settings of its own. У этого принтера нет собственных параметров.
Scroll tabs left Прокрутить вкладки влево
Scroll tabs right Прокрутить вкладки вправо
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Общие свойства полей
+ {n} fields selected Выбрано полей: {n}
+ Mixed Разные значения
+ Custom font Пользовательский шрифт
+ Only checked properties will change. Mixed means the selected fields have different values. Изменятся только отмеченные свойства. «Разные значения» означает, что значения выбранных полей различаются.
+ Shift- or Ctrl/Command-click to select several fields. Щёлкните с Shift или Ctrl/Command, чтобы выбрать несколько полей.
+ Font size 0 uses automatic sizing. Размер шрифта 0 включает автоматический подбор размера.
+ Character limit: Ограничение символов:
+ Character limit 0 means no limit. A comb of characters needs a limit. Ограничение 0 означает отсутствие ограничения. Для посимвольных ячеек нужно задать ограничение.
+ To change locked fields, also set Locked to No. Чтобы изменить заблокированные поля, также установите «Заблокировано» в «Нет».
+ Comb of characters Гребёнка символов
+ The document changed while you were editing field properties. Документ изменился, пока вы редактировали свойства полей.
+ The document is no longer open. Документ больше не открыт.
+ The field properties dialog is no longer open. Окно свойств полей больше не открыто.
+ The field properties could not be changed. Не удалось изменить свойства полей.
+ The selected fields could not be found. Select them again. Выбранные поля не найдены. Выберите их снова.
+ The field {name} no longer exists. Поле {name} больше не существует.
+ A selected field has no widget on any page, so the fields can't be edited together. У одного из выбранных полей нет виджета ни на одной странице, поэтому поля нельзя изменить вместе.
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 9bcb8ed3b..373a6ea77 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -2044,3 +2044,23 @@ action wizard Start ప్రారంభించు
This printer offers no settings of its own. ఈ ప్రింటర్కు దాని స్వంత సెట్టింగ్లు లేవు.
Scroll tabs left ట్యాబ్లను ఎడమకు స్క్రోల్ చేయి
Scroll tabs right ట్యాబ్లను కుడికి స్క్రోల్ చేయి
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties ఉమ్మడి ఫీల్డ్ లక్షణాలు
+ {n} fields selected ఎంచుకున్న ఫీల్డ్లు: {n}
+ Mixed వేర్వేరు విలువలు
+ Custom font అనుకూల ఫాంట్
+ Only checked properties will change. Mixed means the selected fields have different values. టిక్ చేసిన లక్షణాలు మాత్రమే మారుతాయి. “వేర్వేరు విలువలు” అంటే ఎంచుకున్న ఫీల్డ్లలో విలువలు భిన్నంగా ఉన్నాయి.
+ Shift- or Ctrl/Command-click to select several fields. అనేక ఫీల్డ్లను ఎంచుకోవడానికి Shift లేదా Ctrl/Command నొక్కి క్లిక్ చేయండి.
+ Font size 0 uses automatic sizing. ఫాంట్ పరిమాణం 0 అయితే పరిమాణం స్వయంచాలకంగా సర్దుబాటు అవుతుంది.
+ Character limit: అక్షరాల పరిమితి:
+ Character limit 0 means no limit. A comb of characters needs a limit. అక్షరాల పరిమితి 0 అంటే పరిమితి లేదు. అక్షరాల గడులకు పరిమితి అవసరం.
+ To change locked fields, also set Locked to No. లాక్ చేసిన ఫీల్డ్లను మార్చడానికి “లాక్ చేయబడింది” విలువను కూడా “కాదు”గా పెట్టండి.
+ Comb of characters అక్షరాల దువ్వెన
+ The document changed while you were editing field properties. మీరు ఫీల్డ్ లక్షణాలను సవరిస్తున్నప్పుడు పత్రం మారింది.
+ The document is no longer open. పత్రం ఇకపై తెరిచి లేదు.
+ The field properties dialog is no longer open. ఫీల్డ్ లక్షణాల డైలాగ్ ఇకపై తెరిచి లేదు.
+ The field properties could not be changed. ఫీల్డ్ లక్షణాలను మార్చలేకపోయాము.
+ The selected fields could not be found. Select them again. ఎంచుకున్న ఫీల్డ్లు కనుగొనబడలేదు. వాటిని మళ్ళీ ఎంచుకోండి.
+ The field {name} no longer exists. {name} ఫీల్డ్ ఇకపై లేదు.
+ A selected field has no widget on any page, so the fields can't be edited together. ఎంచుకున్న ఒక ఫీల్డ్కు ఏ పేజీలోనూ విడ్జెట్ లేదు, కాబట్టి ఫీల్డ్లను కలిపి సవరించలేము.
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 0921a12aa..42bb556f8 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2136,3 +2136,23 @@ calendar date December грудня
certificate Subject Власник сертифіката
Scroll tabs left Прокрутити вкладки ліворуч
Scroll tabs right Прокрутити вкладки праворуч
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties Спільні властивості полів
+ {n} fields selected Вибрано полів: {n}
+ Mixed Різні значення
+ Custom font Власний шрифт
+ Only checked properties will change. Mixed means the selected fields have different values. Зміняться лише позначені властивості. «Різні значення» означає, що значення вибраних полів відрізняються.
+ Shift- or Ctrl/Command-click to select several fields. Клацніть із Shift або Ctrl/Command, щоб вибрати кілька полів.
+ Font size 0 uses automatic sizing. Розмір шрифту 0 вмикає автоматичний добір розміру.
+ Character limit: Обмеження символів:
+ Character limit 0 means no limit. A comb of characters needs a limit. Обмеження 0 означає відсутність обмеження. Для посимвольних комірок потрібно задати обмеження.
+ To change locked fields, also set Locked to No. Щоб змінити заблоковані поля, також установіть «Заблоковано» в «Ні».
+ Comb of characters Гребінка символів
+ The document changed while you were editing field properties. Документ змінився, поки ви редагували властивості полів.
+ The document is no longer open. Документ більше не відкритий.
+ The field properties dialog is no longer open. Вікно властивостей полів більше не відкрите.
+ The field properties could not be changed. Не вдалося змінити властивості полів.
+ The selected fields could not be found. Select them again. Вибрані поля не знайдено. Виберіть їх знову.
+ The field {name} no longer exists. Поле {name} більше не існує.
+ A selected field has no widget on any page, so the fields can't be edited together. Одне з вибраних полів не має віджета на жодній сторінці, тому поля не можна редагувати разом.
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 9c42b2ae6..4a3644896 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2077,3 +2077,23 @@
This printer offers no settings of its own. 此打印机没有专属设置。
Scroll tabs left 向左滚动选项卡
Scroll tabs right 向右滚动选项卡
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties 共享字段属性
+ {n} fields selected 已选择 {n} 个字段
+ Mixed 混合
+ Custom font 自定义字体
+ Only checked properties will change. Mixed means the selected fields have different values. 只会更改勾选的属性。“混合”表示所选字段的值不同。
+ Shift- or Ctrl/Command-click to select several fields. 按住 Shift 或 Ctrl/Command 单击可选择多个字段。
+ Font size 0 uses automatic sizing. 字号为 0 时自动调整大小。
+ Character limit: 字符数限制:
+ Character limit 0 means no limit. A comb of characters needs a limit. 字符数限制为 0 表示无限制。分格字符必须设置字符数限制。
+ To change locked fields, also set Locked to No. 要更改锁定的字段,还需将“锁定”设为“否”。
+ Comb of characters 字符梳
+ The document changed while you were editing field properties. 编辑字段属性时文档已更改。
+ The document is no longer open. 文档已不再打开。
+ The field properties dialog is no longer open. 字段属性对话框已不再打开。
+ The field properties could not be changed. 无法更改字段属性。
+ The selected fields could not be found. Select them again. 找不到所选字段。请重新选择。
+ The field {name} no longer exists. 字段 {name} 已不存在。
+ A selected field has no widget on any page, so the fields can't be edited together. 某个所选字段在任何页面上都没有控件,因此无法一起编辑这些字段。
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index a45f1cc90..ce5257376 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -1999,3 +1999,23 @@
PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. PdfCraft 正在不使用圖形處理器的情況下繪製,因此可能較慢。更新顯示卡驅動程式可能會有幫助。
Scroll tabs left 向左捲動索引標籤
Scroll tabs right 向右捲動索引標籤
+
+# Shared field properties (Prepare a form).
+ Shared Field Properties 共用欄位屬性
+ {n} fields selected 已選取 {n} 個欄位
+ Mixed 混合
+ Custom font 自訂字型
+ Only checked properties will change. Mixed means the selected fields have different values. 只會變更勾選的屬性。「混合」表示所選欄位的值不同。
+ Shift- or Ctrl/Command-click to select several fields. 按住 Shift 或 Ctrl/Command 點按可選取多個欄位。
+ Font size 0 uses automatic sizing. 字型大小為 0 時自動調整大小。
+ Character limit: 字元數限制:
+ Character limit 0 means no limit. A comb of characters needs a limit. 字元數限制為 0 表示無限制。分格字元必須設定字元數限制。
+ To change locked fields, also set Locked to No. 若要變更鎖定的欄位,也須將「鎖定」設為「否」。
+ Comb of characters 字元梳
+ The document changed while you were editing field properties. 編輯欄位屬性時文件已變更。
+ The document is no longer open. 文件已不再開啟。
+ The field properties dialog is no longer open. 欄位屬性對話方塊已不再開啟。
+ The field properties could not be changed. 無法變更欄位屬性。
+ The selected fields could not be found. Select them again. 找不到所選欄位。請重新選取。
+ The field {name} no longer exists. 欄位 {name} 已不存在。
+ A selected field has no widget on any page, so the fields can't be edited together. 某個所選欄位在任何頁面上都沒有控制項,因此無法一起編輯這些欄位。
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index c75fa73fc..3d2863c7e 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -62,6 +62,7 @@ pub use optimize_ui::{OptimizeDraft, OptimizeTab};
pub use sign_ui::{DigitalIdEntry, SignDraft, SignStep};
mod autoscroll;
mod bidi;
+pub mod bulk_fields;
mod dialogs;
mod edit_text_ui;
mod editing;
@@ -239,6 +240,8 @@ pub enum Dialog {
ReplacePages,
/// Prepare a form ▸ Field Properties.
FieldProps,
+ /// Shared properties of several selected form fields.
+ BulkFieldProps,
/// Redact a PDF ▸ Redact pages, Find text and redact, Set properties, apply confirmation.
RedactPages,
RedactSearch,
@@ -565,6 +568,7 @@ pub struct PdfCraftApp {
/// The Comment Properties dialog's state.
pub comment_props: Option,
pub field_props: Option,
+ pub bulk_field_props: Option,
pub redact_prefs: RedactPrefs,
pub redact_pages_draft: RedactPagesDraft,
pub redact_search: RedactSearchDraft,
@@ -771,6 +775,7 @@ impl PdfCraftApp {
signature_images: Default::default(),
comment_props: None,
field_props: None,
+ bulk_field_props: None,
redact_prefs: RedactPrefs::default(),
redact_pages_draft: RedactPagesDraft::default(),
redact_search: RedactSearchDraft::default(),
@@ -1698,7 +1703,13 @@ impl PdfCraftApp {
&& !ctx.egui_wants_keyboard_input()
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
{
- self.views[i].select_all();
+ if self.is_preparing() {
+ if let Some(doc) = self.session.get(self.views[i].id) {
+ prepare::select_all(&mut self.views[i], &doc.form);
+ }
+ } else {
+ self.views[i].select_all();
+ }
}
self.tool_keys(i, ctx);
canvas::shortcuts(&mut self.views[i], ctx);
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index e8354e03e..5fa525c85 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -394,6 +394,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let mut bm_action: Option = None;
let mut bm_expand: Option = None;
let mut panel_edit: Option = None;
+ let mut field_properties = None;
let mut panel_command: Option<&'static str> = None;
let mut sig_action: Option = None;
let mut a11y_action: Option = None;
@@ -547,7 +548,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
}
RightPanel::Pages => pages(ui, &t, info, view, modal, bm_editable, &mut nav),
- RightPanel::Fields => fields(ui, &t, info, &doc.form, preparing, &mut nav, &mut panel_edit),
+ RightPanel::Fields => field_properties = fields(ui, &t, info, &doc.form, preparing, &mut view.prepare, &mut nav, &mut panel_edit),
RightPanel::Layers => {
if info.layers.is_empty() {
empty(ui, &t, "layers", "This document has no layers.");
@@ -627,6 +628,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if let Some(e) = panel_edit {
app.apply_edit(e);
}
+ if let Some((name, widget)) = field_properties {
+ app.open_field_props(&name, widget);
+ }
if let Some(c) = panel_command {
app.run_command(c);
}
@@ -1095,24 +1099,37 @@ fn page_drag(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, rows: &[R
/// The Fields panel: fields by page, in tab order. While preparing a form, each field can move
/// earlier or later in its page's tab order (Acrobat: Order Tabs Manually).
+#[allow(clippy::too_many_arguments)]
fn fields(
ui: &mut egui::Ui,
t: &Tokens,
info: &DocInfo,
form: &[pdfcraft_engine::FormField],
preparing: bool,
+ selection: &mut crate::prepare::PrepareView,
nav: &mut Option,
edit: &mut Option,
-) {
+) -> Option<(String, usize)> {
+ let mut properties = None;
if info.fields.is_empty() {
empty(ui, t, "text-cursor-input", "This document has no form fields.");
- return;
+ return None;
}
let rank = |name: &str| form.iter().find(|f| f.name == name).and_then(|f| f.widgets.iter().map(|w| w.tab).min()).unwrap_or(usize::MAX);
let mut ordered: Vec<_> = info.fields.iter().collect();
ordered.sort_by_key(|f| (f.page, rank(&f.name)));
if preparing {
ui.label(egui::RichText::new(tl!("Tab order: move a field with its arrows.")).small().color(t.text_muted));
+ ui.label(egui::RichText::new(tl!("Shift- or Ctrl/Command-click to select several fields.")).small().color(t.text_muted));
+ let names = selection.names();
+ // Keep the field rows in place when the first click selects a field, so the
+ // second click of a double-click still lands on the same row.
+ ui.horizontal(|ui| {
+ ui.label(crate::i18n::fmt(tl!("{n} selected"), &[("n", &names.len().to_string())]));
+ if ui.add_enabled(!names.is_empty(), egui::Button::new(tl!("Properties…"))).clicked() {
+ properties = selection.selected.clone();
+ }
+ });
}
let mut pages: Vec> = info.fields.iter().map(|f| f.page).collect();
pages.sort();
@@ -1123,6 +1140,11 @@ fn fields(
ui.add_space(4.0);
ui.label(egui::RichText::new(label).font(theme::semibold(12.5)).color(t.text_muted));
for f in ordered.iter().copied().filter(|f| f.page == p) {
+ let key = form
+ .iter()
+ .find(|field| field.name == f.name)
+ .and_then(|field| field.widgets.iter().position(|w| w.page == f.page).map(|wi| (field.name.clone(), wi)));
+ let selected = key.as_ref().is_some_and(|key| selection.selected.as_ref() == Some(key) || selection.also.contains(key));
let icon = match f.kind {
FieldKind::Text => "text-cursor-input",
FieldKind::CheckBox => "check-circle-2",
@@ -1134,9 +1156,9 @@ fn fields(
FieldKind::Unknown => "square",
};
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &f.name));
- if resp.hovered() {
- ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &f.name));
+ if selected || resp.hovered() {
+ ui.painter().rect_filled(rect, CornerRadius::same(6), if selected { t.pressed } else { t.hover });
}
icons::paint(ui, Rect::from_min_size(rect.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, Color32::from_rgb(0x8E, 0x4E, 0xE6));
ui.painter().text(rect.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, &f.name, theme::regular(13.0), t.text);
@@ -1169,6 +1191,19 @@ fn fields(
}
}
}
+ if preparing && resp.double_clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
+ if let Some(key) = key {
+ if !selected {
+ selection.select(key.clone(), false);
+ }
+ properties = Some(key);
+ }
+ } else if preparing
+ && resp.clicked()
+ && let Some(key) = key
+ {
+ selection.select(key, ui.input(|i| i.modifiers.shift || i.modifiers.command));
+ }
if resp.on_hover_text(tip).clicked()
&& let (Some(p), Some(r)) = (f.page, f.rect)
{
@@ -1176,6 +1211,7 @@ fn fields(
}
}
}
+ properties
}
pub fn human_size(n: usize) -> String {
diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs
index 9b79acc8f..07597c87a 100644
--- a/crates/ui-egui/src/prepare.rs
+++ b/crates/ui-egui/src/prepare.rs
@@ -119,6 +119,8 @@ impl FieldTool {
enum Grab {
/// Drawing a new field from this screen point.
Draw(Pos2),
+ /// Selecting fields inside a dragged rectangle.
+ Select(Pos2),
Move,
/// Resizing by a corner: (moves left edge, moves top edge) in screen terms.
Corner(bool, bool),
@@ -133,6 +135,35 @@ pub struct PrepareView {
/// Select the field an Add field edit creates (the form's length before it).
pub select_added: Option,
grab: Option<(usize, Grab, Pos2)>,
+ marquee_before: Option>,
+ /// Escape ends egui's drag too; ignore its release until the mouse is up.
+ ignore_release: bool,
+}
+
+impl PrepareView {
+ /// Unique field names: selecting several widgets of one radio group still edits one field.
+ pub fn names(&self) -> Vec {
+ let mut names = Vec::new();
+ for (name, _) in self.selected.iter().chain(self.also.iter()) {
+ if !names.contains(name) {
+ names.push(name.clone());
+ }
+ }
+ names
+ }
+
+ pub(crate) fn select(&mut self, key: (String, usize), extend: bool) {
+ if !extend || self.selected.is_none() {
+ self.selected = Some(key);
+ self.also.clear();
+ } else if self.selected.as_ref() == Some(&key) {
+ self.selected = if self.also.is_empty() { None } else { Some(self.also.remove(0)) };
+ } else if let Some(index) = self.also.iter().position(|k| *k == key) {
+ self.also.remove(index);
+ } else {
+ self.also.push(key);
+ }
+ }
}
fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
@@ -173,7 +204,7 @@ fn dragged(r: Rect, grab: Grab, delta: egui::Vec2) -> Rect {
}
Rect::from_two_pos(r.min, r.max)
}
- Grab::Draw(_) => r,
+ Grab::Draw(_) | Grab::Select(_) => r,
}
}
@@ -203,6 +234,19 @@ pub(crate) fn page_input(
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
let prep = &mut view.prepare;
+ if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
+ cancel_marquee(prep);
+ out.consumed = true;
+ return out;
+ }
+ if prep.ignore_release {
+ if !ui.input(|i| i.pointer.primary_down()) {
+ prep.ignore_release = false;
+ }
+ out.consumed = true;
+ return out;
+ }
+
// A grab in progress on this page.
if let Some((gp, grab, start)) = prep.grab
&& gp == page
@@ -212,6 +256,22 @@ pub(crate) fn page_input(
prep.grab = None;
let end = pointer.unwrap_or(start);
match grab {
+ Grab::Select(from) => {
+ prep.marquee_before = None;
+ let rect = Rect::from_two_pos(from, end).intersect(xf.rect);
+ for f in form {
+ for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page)) {
+ if screen_rect(xf, info, page, w.rect).intersects(rect) {
+ let key = (f.name.clone(), wi);
+ if prep.selected.is_none() {
+ prep.selected = Some(key);
+ } else if prep.selected.as_ref() != Some(&key) && !prep.also.contains(&key) {
+ prep.also.push(key);
+ }
+ }
+ }
+ }
+ }
Grab::Draw(from) => {
let Some(tool) = tool else { return out };
let r = Rect::from_two_pos(from, end).intersect(xf.rect);
@@ -317,10 +377,18 @@ pub(crate) fn page_input(
prep.selected = Some((f.name.clone(), wi));
prep.grab = Some((page, Grab::Move, origin));
out.consumed = true;
+ } else {
+ prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect());
+ if !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
+ prep.selected = None;
+ prep.also.clear();
+ }
+ prep.grab = Some((page, Grab::Select(origin), origin));
+ out.consumed = true;
}
return out;
}
- if resp.double_clicked() && hit.is_some() {
+ if resp.double_clicked() && hit.is_some() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
out.consumed = true;
out.properties = true;
return out;
@@ -329,20 +397,8 @@ pub(crate) fn page_input(
let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command);
match hit {
// Shift/⌘-click adds or removes a field from the selection.
- Some((f, wi)) if extend && prep.selected.is_some() => {
- let key = (f.name.clone(), wi);
- if prep.selected.as_ref() != Some(&key) {
- if let Some(i) = prep.also.iter().position(|k| *k == key) {
- prep.also.remove(i);
- } else {
- prep.also.push(key);
- }
- }
- out.consumed = true;
- }
Some((f, wi)) => {
- prep.selected = Some((f.name.clone(), wi));
- prep.also.clear();
+ prep.select((f.name.clone(), wi), extend);
out.consumed = true;
}
None if corner.is_none() => {
@@ -491,7 +547,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
}
}
}
- if let (Some((_, Grab::Draw(from), _)), Some(p)) = (grab, pointer) {
+ if let (Some((_, Grab::Draw(from) | Grab::Select(from), _)), Some(p)) = (grab, pointer) {
let r = Rect::from_two_pos(from, p).intersect(xf.rect);
painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL);
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Inside);
@@ -516,8 +572,32 @@ pub(crate) fn delete_selected(view: &mut DocView) -> Option {
}
/// Delete removes the selected fields; Escape clears the selection.
+pub(crate) fn select_all(view: &mut DocView, form: &[FormField]) {
+ let mut selected =
+ form.iter().flat_map(|f| f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(view.current)).map(|(wi, _)| (f.name.clone(), wi)));
+ view.prepare.selected = selected.next();
+ view.prepare.also = selected.collect();
+}
+
+fn cancel_marquee(prep: &mut PrepareView) {
+ prep.grab = None;
+ prep.ignore_release = true;
+ if let Some(before) = prep.marquee_before.take() {
+ let mut before = before.into_iter();
+ prep.selected = before.next();
+ prep.also = before.collect();
+ }
+}
+
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {
- if view.prepare.selected.is_none() || ctx.egui_wants_keyboard_input() {
+ if ctx.egui_wants_keyboard_input() {
+ return;
+ }
+ if view.prepare.marquee_before.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
+ cancel_marquee(&mut view.prepare);
+ return;
+ }
+ if view.prepare.selected.is_none() {
return;
}
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
@@ -531,6 +611,7 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {
/// After an Add field edit: select the new field (the form's last).
pub(crate) fn after_refresh(view: &mut DocView, form: &[FormField]) {
+ view.prepare.also.retain(|(name, wi)| form.iter().any(|f| &f.name == name && *wi < f.widgets.len()));
if let Some(n) = view.prepare.select_added.take() {
if form.len() > n
&& let Some(f) = form.last()
@@ -555,7 +636,21 @@ pub(crate) fn after_refresh(view: &mut DocView, form: &[FormField]) {
impl crate::PdfCraftApp {
/// Open Field Properties for a field of the active document.
pub fn open_field_props(&mut self, name: &str, widget: usize) {
- let Some((_, id)) = self.active_ids() else { return };
+ let Some((index, id)) = self.active_ids() else { return };
+ let names = self.views[index].prepare.names();
+ if names.len() > 1 && names.iter().any(|n| n == name) {
+ let draft = self.session.get(id).map_or(Err("The document is no longer open."), |doc| crate::bulk_fields::Draft::new(doc, names));
+ match draft {
+ Ok(draft) => {
+ self.field_props = None;
+ self.bulk_field_props = Some(draft);
+ self.dialog = Some(crate::Dialog::BulkFieldProps);
+ }
+ Err(why) => self.notify_tr(why),
+ }
+ return;
+ }
+ self.bulk_field_props = None;
let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).cloned()) else { return };
let mut d = FieldDraft::new(&f, widget);
d.look = self.session.get(id).and_then(|doc| doc.field_look(name));
@@ -682,9 +777,28 @@ pub struct FieldDraft {
original: Box>,
}
-fn da_size(da: &str) -> f64 {
- let toks: Vec<&str> = da.split_whitespace().collect();
- toks.windows(2).rev().find(|w| w[1] == "Tf").and_then(|w| w[0].parse().ok()).unwrap_or(0.0)
+pub(crate) fn da_size(da: &str) -> f64 {
+ let mut previous = "";
+ let mut size = 0.0;
+ for token in da.split_whitespace() {
+ if token == "Tf" {
+ size = previous.parse::().ok().filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
+ }
+ previous = token;
+ }
+ size
+}
+
+pub(crate) fn da_font(da: &str) -> &str {
+ let (mut two_back, mut previous, mut font) = ("", "", "Helv");
+ for token in da.split_whitespace() {
+ if token == "Tf" {
+ font = two_back.trim_start_matches('/');
+ }
+ two_back = previous;
+ previous = token;
+ }
+ font
}
impl FieldDraft {
@@ -769,6 +883,7 @@ impl FieldDraft {
}),
rotation: (self.rotation != o.rotation).then_some((self.widget, self.rotation)),
look: (self.look != o.look).then_some(self.look).flatten(),
+ appearance: None,
check_style: (self.check_style != o.check_style).then_some(self.check_style).flatten(),
format: (self.format != o.format).then(|| self.format.clone()),
validate: (self.validate != o.validate).then(|| self.validate.clone()),
diff --git a/crates/ui-egui/tests/bulk_fields.rs b/crates/ui-egui/tests/bulk_fields.rs
new file mode 100644
index 000000000..b2c75c262
--- /dev/null
+++ b/crates/ui-egui/tests/bulk_fields.rs
@@ -0,0 +1,361 @@
+//! Shared properties and multi-selection through the real Prepare-a-form shell.
+
+use egui_kittest::{Harness, kittest::Queryable};
+use pdfcraft_engine::{Edit, FieldFont, FieldProps, FieldValue, NewField, Session, field_flags};
+use pdfcraft_ui_egui::{Dialog, PdfCraftApp, bulk_fields::Tab};
+
+fn fixture() -> Vec {
+ let mut s = Session::new();
+ let bytes = s.create_blank(300.0, 400.0, 2).unwrap();
+ let id = s.open("form.pdf", None, bytes, None).unwrap();
+ for (name, page, rect, kind) in [
+ ("alpha", 0, [40.0, 290.0, 230.0, 314.0], NewField::Text { multiline: false }),
+ ("beta", 0, [40.0, 240.0, 230.0, 264.0], NewField::Text { multiline: false }),
+ ("consent", 0, [40.0, 180.0, 54.0, 194.0], NewField::CheckBox),
+ ("gamma", 1, [40.0, 290.0, 230.0, 314.0], NewField::Text { multiline: false }),
+ ] {
+ s.apply(id, Edit::AddField { name: Some(name.into()), page, rect, kind }).unwrap();
+ }
+ for (name, value, fill, font, width, size, required) in [
+ ("alpha", "Ada", [1.0, 0.9, 0.8], FieldFont::Courier, 1.0, 11.0, true),
+ ("beta", "Lovelace", [0.8, 0.9, 1.0], FieldFont::Times, 3.0, 18.0, false),
+ ] {
+ s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text(value.into()) }).unwrap();
+ let mut look = s.get(id).unwrap().field_look(name).unwrap();
+ look.fill = Some(fill);
+ look.font = font;
+ look.width = width;
+ s.apply(
+ id,
+ Edit::SetFieldProps {
+ name: name.into(),
+ props: Box::new(FieldProps {
+ tooltip: Some(format!("Help for {name}")),
+ look: Some(look),
+ required: Some(required),
+ font_size: Some(size),
+ ..Default::default()
+ }),
+ },
+ )
+ .unwrap();
+ }
+ s.save_bytes(id).unwrap().as_ref().clone()
+}
+
+fn harness() -> Harness<'static, PdfCraftApp> {
+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ // Saves stamp /ModDate from the clock; pinned, two saves compare equal across a second.
+ app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000);
+ app.open_bytes("form.pdf", None, fixture()).unwrap();
+ app.set_option("zoom", "150").unwrap();
+ app.execute("form.prepare");
+ app
+ });
+ h.run_steps(4);
+ h
+}
+
+fn doc<'a>(h: &'a Harness<'static, PdfCraftApp>) -> &'a pdfcraft_engine::Document {
+ let s = h.state();
+ s.session.get(s.views[0].id).unwrap()
+}
+
+fn bytes(h: &Harness<'static, PdfCraftApp>) -> Vec {
+ h.state().session.save_bytes(h.state().views[0].id).unwrap().as_ref().clone()
+}
+
+fn select(h: &mut Harness<'static, PdfCraftApp>, names: &[&str]) {
+ let p = &mut h.state_mut().views[0].prepare;
+ p.selected = names.first().map(|n| (n.to_string(), 0));
+ p.also = names.iter().skip(1).map(|n| (n.to_string(), 0)).collect();
+ h.state_mut().execute("form.field.properties");
+ h.run_steps(4);
+}
+
+#[test]
+fn shared_properties_preserve_mixed_values_and_undo_the_whole_change() {
+ let mut h = harness();
+ let before = bytes(&h);
+ let original = [doc(&h).field_look("alpha").unwrap(), doc(&h).field_look("beta").unwrap()];
+ select(&mut h, &["alpha", "beta"]);
+ h.get_by_label("Shared Field Properties");
+ h.get_by_label("2 fields selected");
+ assert!(h.state().bulk_field_props.as_ref().unwrap().required.mixed);
+ h.get_by_label("Required").click();
+ h.run_steps(4);
+ h.state_mut().bulk_field_props.as_mut().unwrap().required.value = true;
+ h.get_by_label("Appearance").click();
+ h.run_steps(4);
+ h.get_by_label("Line Thickness:").click();
+ h.run_steps(4);
+ h.state_mut().bulk_field_props.as_mut().unwrap().width.value = 4.0;
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert!(h.state().dialog.is_none());
+ for (name, look, value) in [("alpha", original[0], "Ada"), ("beta", original[1], "Lovelace")] {
+ let field = doc(&h).form.iter().find(|f| f.name == name).unwrap();
+ assert!(field.has(field_flags::REQUIRED));
+ assert_eq!(field.value, [value]);
+ assert_eq!(field.tooltip.as_deref(), Some(format!("Help for {name}").as_str()));
+ let new = doc(&h).field_look(name).unwrap();
+ assert_eq!(new.width, 4.0);
+ assert_eq!((new.fill, new.font, new.text, new.border), (look.fill, look.font, look.text, look.border));
+ }
+ assert_eq!(doc(&h).can_undo(), Some("Change field properties"));
+ assert_eq!(h.state().views[0].prepare.names(), ["alpha", "beta"]);
+ h.state_mut().execute("edit.undo");
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before);
+ h.state_mut().execute("edit.redo");
+ h.run_steps(4);
+ assert_eq!(doc(&h).field_look("beta").unwrap().width, 4.0);
+ // The regenerated look also survives a real Save/open round trip.
+ let mut s = Session::new();
+ let id = s.open("saved.pdf", None, std::sync::Arc::new(bytes(&h)), None).unwrap();
+ assert_eq!(s.get(id).unwrap().field_look("beta").unwrap().fill, original[1].fill);
+ assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "beta").unwrap().value, ["Lovelace"]);
+}
+
+#[test]
+fn visiting_shared_property_tabs_without_changes_does_not_dirty_the_document() {
+ let mut h = harness();
+ let before = bytes(&h);
+ select(&mut h, &["alpha", "beta"]);
+ for tab in ["Appearance", "Options", "General"] {
+ h.get_by_label(tab).click();
+ h.run_steps(4);
+ }
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before);
+ assert!(!doc(&h).dirty);
+ assert!(doc(&h).can_undo().is_none());
+}
+
+#[test]
+fn cancelling_shared_properties_discards_only_the_draft() {
+ let mut h = harness();
+ let before = bytes(&h);
+ select(&mut h, &["alpha", "beta", "consent"]);
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.fill.apply = true;
+ d.fill.value = None;
+ h.get_by_label("Cancel").click();
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before);
+ assert!(h.state().bulk_field_props.is_none());
+ assert_eq!(h.state().views[0].prepare.names(), ["alpha", "beta", "consent"]);
+}
+
+#[test]
+fn locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction() {
+ let mut h = harness();
+ h.state_mut().apply_edit(Edit::SetFieldProps { name: "beta".into(), props: Box::new(FieldProps { locked: Some(true), ..Default::default() }) });
+ let before = bytes(&h);
+ select(&mut h, &["alpha", "beta"]);
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.tooltip.apply = true;
+ d.tooltip.value = "Shared help".into();
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before, "alpha must not be committed before beta refuses");
+ assert_eq!(h.state().dialog, Some(Dialog::BulkFieldProps));
+ assert!(h.state().bulk_field_props.as_ref().unwrap().error.as_ref().unwrap().contains("locked"));
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.locked.apply = true;
+ d.locked.value = false;
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert!(h.state().dialog.is_none());
+ assert!(
+ doc(&h)
+ .form
+ .iter()
+ .filter(|f| ["alpha", "beta"].contains(&f.name.as_str()))
+ .all(|f| !f.locked() && f.tooltip.as_deref() == Some("Shared help"))
+ );
+ h.state_mut().execute("edit.undo");
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before, "unlock and changes undo together");
+}
+
+#[test]
+fn shared_properties_cannot_apply_to_a_different_active_document() {
+ let mut h = harness();
+ let before = bytes(&h);
+ select(&mut h, &["alpha", "beta"]);
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.required.apply = true;
+ d.required.value = true;
+ h.state_mut().open_bytes("other.pdf", None, fixture()).unwrap();
+ h.run_steps(4);
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before);
+ let id = h.state().views[1].id;
+ assert!(!h.state().session.get(id).unwrap().dirty);
+ assert!(h.state().bulk_field_props.as_ref().unwrap().error.as_ref().unwrap().contains("document changed"));
+}
+
+#[test]
+fn deleting_a_field_while_properties_are_open_does_not_partly_apply_the_draft() {
+ let mut h = harness();
+ select(&mut h, &["alpha", "beta"]);
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.tooltip.apply = true;
+ d.tooltip.value = "New help".into();
+ h.state_mut().apply_edit(Edit::DeleteField { name: "beta".into() });
+ let before = bytes(&h);
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before);
+ assert!(h.state().bulk_field_props.as_ref().unwrap().error.as_ref().unwrap().contains("no longer exists"));
+}
+
+#[test]
+fn select_all_in_prepare_a_form_selects_only_the_current_pages_fields() {
+ let mut h = harness();
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].prepare.names(), ["alpha", "beta", "consent"]);
+ assert!(!doc(&h).dirty);
+ h.state_mut().views[0].go_to_page(1);
+ h.run_steps(4);
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].prepare.names(), ["gamma"]);
+}
+
+#[test]
+fn fields_panel_selects_multiple_fields_and_opens_their_shared_properties() {
+ let mut h = harness();
+ h.get_by_label("alpha").click();
+ h.run_steps(4);
+ h.get_by_label("beta").click_modifiers(egui::Modifiers::SHIFT);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].prepare.names(), ["alpha", "beta"]);
+ h.get_by_label("Properties…").click();
+ h.run_steps(4);
+ h.get_by_label("Shared Field Properties");
+ h.get_by_label("Cancel").click();
+ h.run_steps(4);
+ h.get_by_label("beta").click_modifiers(egui::Modifiers::COMMAND);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].prepare.names(), ["alpha"]);
+}
+
+#[test]
+fn double_clicking_a_field_in_the_panel_opens_its_individual_properties() {
+ let mut h = harness();
+ for _ in 0..2 {
+ h.get_by_label("beta").click();
+ h.run_steps(4);
+ }
+ h.run_steps(4);
+ h.get_by_label("Text Field Properties");
+ assert_eq!(h.state().field_props.as_ref().unwrap().field, "beta");
+}
+
+#[test]
+fn dragging_an_empty_page_area_selects_fields_without_moving_them() {
+ let mut h = harness();
+ let before = bytes(&h);
+ let r = h.state().views[0].page_screen_rect(0).unwrap();
+ let scale = r.width() / 300.0;
+ let a = r.min + egui::vec2(30.0, 75.0) * scale;
+ let b = r.min + egui::vec2(240.0, 165.0) * scale;
+ h.hover_at(a);
+ h.run_steps(4);
+ h.drag_at(a);
+ h.run_steps(4);
+ for step in 1..=4 {
+ h.hover_at(a + (b - a) * (step as f32 / 4.0));
+ h.run_steps(4);
+ }
+ h.drop_at(b);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].prepare.names(), ["alpha", "beta"]);
+ assert_eq!(bytes(&h), before);
+}
+
+#[test]
+fn selecting_several_widgets_of_one_radio_group_does_not_duplicate_field_edits() {
+ let mut h = harness();
+ for (export, x) in [("A", 100.0), ("B", 150.0)] {
+ h.state_mut().apply_edit(Edit::AddField {
+ page: 0,
+ rect: [x, 180.0, x + 14.0, 194.0],
+ kind: NewField::Radio { group: Some("group".into()), export: export.into() },
+ name: None,
+ });
+ }
+ let p = &mut h.state_mut().views[0].prepare;
+ p.selected = Some(("group".into(), 0));
+ p.also = vec![("group".into(), 1), ("alpha".into(), 0)];
+ h.state_mut().execute("form.field.properties");
+ h.run_steps(4);
+ let d = h.state().bulk_field_props.as_ref().unwrap();
+ assert_eq!(d.names, ["group", "alpha"]);
+ assert_eq!(d.tab, Tab::General);
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.tooltip.apply = true;
+ d.tooltip.value = "Shared help".into();
+ let edits = d.clone().edits(doc(&h)).unwrap();
+ assert_eq!(edits.len(), 2);
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ let group = doc(&h).form.iter().find(|f| f.name == "group").unwrap();
+ assert_eq!(group.widgets.len(), 2);
+ assert_eq!(group.tooltip.as_deref(), Some("Shared help"));
+}
+
+#[test]
+fn escape_cancels_a_selection_rectangle_and_restores_the_previous_selection() {
+ let mut h = harness();
+ h.state_mut().views[0].prepare.selected = Some(("consent".into(), 0));
+ let before = bytes(&h);
+ let r = h.state().views[0].page_screen_rect(0).unwrap();
+ let scale = r.width() / 300.0;
+ let a = r.min + egui::vec2(30.0, 75.0) * scale;
+ let b = r.min + egui::vec2(240.0, 165.0) * scale;
+ h.hover_at(a);
+ h.run_steps(4);
+ h.drag_at(a);
+ h.run_steps(4);
+ h.hover_at(b);
+ h.run_steps(4);
+ h.key_press(egui::Key::Escape);
+ h.run_steps(4);
+ h.drop_at(b);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].prepare.names(), ["consent"]);
+ assert_eq!(bytes(&h), before);
+}
+
+#[test]
+fn shared_properties_preserve_unchecked_changes_made_after_the_dialog_opened() {
+ let mut h = harness();
+ select(&mut h, &["alpha", "beta"]);
+ let d = h.state_mut().bulk_field_props.as_mut().unwrap();
+ d.width.apply = true;
+ d.width.value = 2.0;
+ h.state_mut().apply_edit(Edit::SetFieldProps {
+ name: "beta".into(),
+ props: Box::new(FieldProps {
+ appearance: Some(pdfcraft_engine::FieldLookPatch { fill: Some(Some([0.3, 0.7, 0.1])), ..Default::default() }),
+ ..Default::default()
+ }),
+ });
+ let before = bytes(&h);
+ h.run_steps(4);
+ h.get_by_label("OK").click();
+ h.run_steps(4);
+ assert_eq!(doc(&h).field_look("beta").unwrap().fill, Some([0.3, 0.7, 0.1]));
+ assert_eq!(doc(&h).field_look("beta").unwrap().width, 2.0);
+ h.state_mut().execute("edit.undo");
+ h.run_steps(4);
+ assert_eq!(bytes(&h), before);
+}
diff --git a/crates/ui-egui/tests/links.rs b/crates/ui-egui/tests/links.rs
index d3ee78196..199fdb3f6 100644
--- a/crates/ui-egui/tests/links.rs
+++ b/crates/ui-egui/tests/links.rs
@@ -70,16 +70,18 @@ fn help_commands_open_each_link() {
fn about_dialog_has_contributors_and_models_tabs() {
let mut h = harness(|app| app.dialog = Some(Dialog::About));
h.get_by_label("Contributors").click();
- h.run_steps(2);
+ // Tab changes resize and recenter the modal as the compiled-in credits grow.
+ // Let its layout settle before clicking the next control at its new position.
+ h.run_steps(4);
// The owner is always in the compiled-in credits (contributors/contributors.json), shown by username.
h.get_by_label("@echelon");
h.get_by_label("Table").click();
- h.run_steps(2);
+ h.run_steps(4);
h.get_by_label("PRs");
h.get_by_label("Display name").click();
- h.run_steps(2);
+ h.run_steps(4);
h.get_by_label("Brandon Thomas");
h.get_by_label("Models").click();
- h.run_steps(2);
+ h.run_steps(4);
assert!(h.query_all_by_label("Anthropic").count() >= 1);
}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 0f65485ae..e5044faf2 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -4304,6 +4304,8 @@ commands = ["form.fields"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed"]
notes = "read-only list with jump-to-field; filling and editing are separate features (M6); fields a file lists only as page widgets (empty /Fields) are adopted too"
+evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page", "crates/ui-egui/tests/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"]
+notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties"
[[feature]]
id = "form.highlight-fields"
@@ -4662,8 +4664,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.prepare"]
tools = ["form_add_field", "form_set_props", "form_delete_field"]
-evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields"]
-notes = "fields shown with blue fill and name tags; select, move, resize by corners, Delete (every selected field, one undo step); field tools return to Select after one field. Not yet: editing shared properties of several fields at once"
+evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"]
+notes = "fields shown with blue fill and name tags; select, move, resize by corners, Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits"
[[feature]]
id = "form.add-text-field"
@@ -4806,8 +4808,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
-notes = "name, tooltip, read only, required (visibility/orientation pending)"
+evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document"]
+notes = "name, tooltip, read only, required, locked; shared tooltip/flags are opt-in, mixed values preserved, failures keep the draft and roll back the batch (visibility pending)"
[[feature]]
id = "form.props-appearance"
@@ -4818,8 +4820,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools"]
-notes = "border/fill colour, thin/medium/thick, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier, font size"
+evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::partial_appearance_keeps_custom_default_appearance_and_font_resources", "crates/forms/src/tests.rs::partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions", "crates/automation/tests/form_bulk.rs::bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save"]
+notes = "border/fill colour, line width, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier, font size; shared GUI and partial tool edits preserve untouched custom fonts, widget styles and unknown keys. Appearance regeneration still uses approximate metrics"
[[feature]]
id = "form.props-position"
@@ -4842,8 +4844,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
-evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults"]
-notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison"
+evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
+notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
[[feature]]
id = "form.props-actions"