mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:59:48 +00:00
M6: renaming a field renames it in the calculations that read it (#3550)
* M6: renaming a field renames it in the calculations that read it A rename silently broke every formula that used the field: a sum dropped it, notation read it as 0 and a script kept a stale value. set_props now rewrites the name in each field's Calculate script, in the same edit: AFSimple_Calculate field lists, simplified field notation (the BVCALC comment and its translation) and getField calls naming it in one string literal. The rest of each script is kept as written; names built at run time, non-UTF-8 script streams and a name another field still has are left alone, and a self-check keeps a script whose meaning would change. Notation is read by one lexer shared by notation_js, eval_notation and the rename. form_set_props returns formulas_updated; Field Properties says how many calculations now use the new name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: form_set_props leaves the renamed field out of formulas_updated by object The renamed field was matched by the name given, but set_props stores it trimmed and under its parent's prefix. When the same call changed the field's own formula, formulas_updated listed the field as one the rename had rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: aiongg <aiongtaigi@gmail.com>
This commit is contained in:
+22
-22
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "467f1e5b55c0f036b35bb72b8a23c7cd0a8bd96ae188d742393b7dc0635166df"
|
||||
sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "4a9d4428c3f67e256aa2f9e9d2d057c0e40f60a4446a0b384f3055894a9a9213"
|
||||
sha256 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f22298173d1e174feb83f6fb20e7079bb9091df86f396c69648b974f429be158"
|
||||
sha256 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e320d5f50795dc1c46d589d72c7a27facb6164938cf9d75151e904b7450b142d"
|
||||
sha256 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0dd7e0e0b0843b519cebc5504ce7006e4c9cb1eef02b270ed52d008972ee7ff3"
|
||||
sha256 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "dda5b3aea60d718eb2fe28e02a76728a3c895487e03f9319c2bcdef7ea2012db"
|
||||
sha256 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3351f6abd5e2796ad5e70b00604c9a7093387243ad1951713a717d06bffe419d"
|
||||
sha256 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2595a893a3cb84b6f0344b9e2144d9e956e80f778eb7d045fa2ad503e3cfb267"
|
||||
sha256 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ec2d2afca09390c809e805a52108f5e8c94bf61feef7d0ba2901723db747323d"
|
||||
sha256 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "683f515a21dbc4b22b312c63e39b4d2a572a1f1cf733f02127f1ee62dbb7a05e"
|
||||
sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e31d3969393994c33b90cd5fa82e80584955e4e0676df37136e345db476623ed"
|
||||
sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "fee17b5090aba63d3129772c271d9802444918fe3a905734b41e006e320e9234"
|
||||
sha256 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ef26690aeec1c4b6b648ac50bc2433fec681524ab98e6270cabd3d76be6d3e14"
|
||||
sha256 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2452a0f56f455ad71cae439a2ecf6da0ae5e2aaa90790de4d16e222393030283"
|
||||
sha256 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "bbf8c454edc80ddeaecce63e56f5bceba335ae6cce8e4456b14742cd3a820bfe"
|
||||
sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "64d3c1c532ecb94b44b2c894acdaf171a130cbaa8e173b099f9b9b249d2134f4"
|
||||
sha256 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "9e87a8f108b49213c7bb0f077a2e2f1bae449e1459a908d8202beba280d8055c"
|
||||
sha256 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "921e8e906bdc42675fe8922fc00835a255e019ed61688a01095367f6e881f187"
|
||||
sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "59070be5688d7d183a7354f08f9604e8f962dcc5e223dd91d1cd940997c47399"
|
||||
sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f48accb38d4c824cb3d2f71fcffe0d8edb0a9a5f7412b9c479da6fb2479e45d2"
|
||||
sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc"
|
||||
|
||||
|
||||
[[asset]]
|
||||
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "42212a46ddca212bb94c15062be3b444b33181ea68cdd9880fc5191f56d986a5"
|
||||
sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "fcc38245d923c4ddefb5560fe7fdfe5bc6c1b9c49d4fcb25f2873e63dff4f806"
|
||||
sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8"
|
||||
|
||||
[[asset]]
|
||||
|
||||
|
||||
Generated
+1
@@ -4177,6 +4177,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
|
||||
|
||||
@@ -386,12 +386,24 @@ impl Automation {
|
||||
} else {
|
||||
edits.pop().ok_or_else(|| bad("give a field name"))?
|
||||
};
|
||||
// A rename also renames the field in the formulas that read it.
|
||||
let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None };
|
||||
let mut out = self.apply(a, edit)?;
|
||||
if bulk {
|
||||
out["fields"] = json!(names);
|
||||
} else {
|
||||
out["field"] = json!(new_name);
|
||||
}
|
||||
if let Some(before) = before {
|
||||
let after = &self.doc(a)?.form;
|
||||
// The renamed field itself, found by object: the name stored can differ from the
|
||||
// argument (trimmed, kept under its parent's prefix).
|
||||
let own =
|
||||
names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name);
|
||||
let mut updated = pdfcraft_engine::form_calculations_changed(&before, after);
|
||||
updated.retain(|f| Some(f) != own);
|
||||
out["formulas_updated"] = json!(updated);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"form_set_props",
|
||||
"Field properties",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
|
||||
@@ -3799,6 +3799,60 @@ fn form_formats_and_calculations_through_tools() {
|
||||
));
|
||||
}
|
||||
|
||||
/// Renaming a field renames it in the formulas that read it: a sum dropped the field, notation
|
||||
/// read it as 0 and a script kept a stale value.
|
||||
#[test]
|
||||
fn renaming_a_field_keeps_the_formulas_that_read_it() {
|
||||
let dir = workdir("formrename");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (i, name) in ["a", "b", "c", "d", "e", "x", "sum", "product", "double", "total"].into_iter().enumerate() {
|
||||
let y = 20 + 30 * i;
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
|
||||
}
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sum", "calculate": { "op": "sum", "fields": ["a", "b"] } }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "product", "calculate": { "notation": "c * d" } }));
|
||||
ok(
|
||||
&mut a,
|
||||
"form_set_script",
|
||||
json!({ "doc": doc, "field": "double", "event": "calculate", "script": "event.value = getField(\"e\").value * 2;" }),
|
||||
);
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["x"] } }));
|
||||
let renamed = |a: &mut Automation, from: &str, to: &str| ok(a, "form_set_props", json!({ "doc": doc, "field": from, "name": to }));
|
||||
assert_eq!(renamed(&mut a, "a", "qty")["formulas_updated"], json!(["sum"]));
|
||||
assert_eq!(renamed(&mut a, "c", "price")["formulas_updated"], json!(["product"]));
|
||||
assert_eq!(renamed(&mut a, "e", "base")["formulas_updated"], json!(["double"]));
|
||||
assert_eq!(renamed(&mut a, "total", "grand")["formulas_updated"], json!([]), "a calculated field's own name is in no formula");
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "10", "b": "2", "price": "10", "d": "4", "base": "10", "x": "9" } }));
|
||||
let values = |a: &mut Automation| {
|
||||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
let value = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone());
|
||||
[value("sum"), value("product"), value("double"), value("grand")]
|
||||
};
|
||||
assert_eq!(values(&mut a), [Some(json!("12")), Some(json!("40")), Some(json!("20")), Some(json!("9"))]);
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap().clone();
|
||||
assert_eq!(field("sum")["calculate"]["fields"], json!(["qty", "b"]));
|
||||
assert_eq!(field("product")["calculate"]["notation"], "price * d");
|
||||
// The rename and the formulas it changed are one undo step: undo the fill, then the last
|
||||
// three renames.
|
||||
for _ in 0..4 {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
}
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned();
|
||||
assert!(field("c").is_some() && field("price").is_none());
|
||||
assert_eq!(field("product").unwrap()["calculate"]["notation"], "c * d", "undone together with the rename");
|
||||
// The renamed field's own formula, changed in the same call, is not one the rename
|
||||
// rewrote, though the name is stored trimmed.
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"form_set_props",
|
||||
json!({ "doc": doc, "field": "sum", "name": " added ", "calculate": { "op": "sum", "fields": ["qty", "b", "x"] } }),
|
||||
);
|
||||
assert_eq!(r["formulas_updated"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_order_and_field_appearance_through_tools() {
|
||||
let dir = workdir("taborder");
|
||||
|
||||
@@ -58,7 +58,8 @@ pub use pdfcraft_edit::{
|
||||
pub use pdfcraft_forms::{
|
||||
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
|
||||
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
|
||||
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
|
||||
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed,
|
||||
flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
|
||||
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
|
||||
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
|
||||
buttons joining a group become kid widgets with their own export state. Renaming changes only
|
||||
the last part of a dotted name.
|
||||
- Renaming a field renames it in every Calculate script that reads it
|
||||
(`af::rename_in_calculation`): `AFSimple_Calculate` field lists, simplified field notation
|
||||
(the `BVCALC` comment and its translation), and `getField` calls whose argument is the name as
|
||||
one string literal. The rest of each script is kept as written. Names built at run time, a
|
||||
script stream that isn't UTF-8, other events' scripts and a name another field still has are
|
||||
left alone. `calculations_changed` compares two readings of a form to say which formulas an
|
||||
edit rewrote.
|
||||
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
|
||||
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
|
||||
that label, independently of the field name. The index is 0-based. A label-only edit redraws
|
||||
|
||||
+418
-51
@@ -11,6 +11,7 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Write as _;
|
||||
use std::ops::Range;
|
||||
|
||||
/// The Format tab.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
@@ -453,6 +454,250 @@ pub fn calculate_js(c: &Calculate) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── renaming fields in calculations ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A JavaScript token, as far as finding the field names a calculation reads needs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Js {
|
||||
Ident,
|
||||
/// A string literal, its escapes undone.
|
||||
Str(String),
|
||||
Punct(char),
|
||||
/// A number, a regular expression or a template literal.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Words after which `/` starts a regular expression instead of dividing.
|
||||
const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"];
|
||||
|
||||
/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell
|
||||
/// code from strings, comments and regular expressions. The tokens stop where a string, comment
|
||||
/// or template literal never ends.
|
||||
fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
|
||||
let s = js.as_bytes();
|
||||
// Bytes past ASCII are parts of identifiers, so a range never ends inside a character.
|
||||
let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii();
|
||||
let mut out: Vec<(Range<usize>, Js)> = Vec::new();
|
||||
let mut i = 0;
|
||||
// Where a regular expression that never ended stopped being read: every `/` before it
|
||||
// divides, so a line of them is read once, not once for each.
|
||||
let mut divides_until = 0;
|
||||
'scan: while let Some(&c) = s.get(i) {
|
||||
let start = i;
|
||||
let token = match c {
|
||||
_ if c.is_ascii_whitespace() => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
b'/' if s.get(i + 1) == Some(&b'/') => {
|
||||
i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n);
|
||||
continue;
|
||||
}
|
||||
b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) {
|
||||
Some(n) => {
|
||||
i += n + 4;
|
||||
continue;
|
||||
}
|
||||
None => break 'scan,
|
||||
},
|
||||
b'"' | b'\'' => {
|
||||
let mut p = Parser { s, i, depth: 0 };
|
||||
let Some(text) = p.string() else { break 'scan };
|
||||
i = p.i;
|
||||
Js::Str(text)
|
||||
}
|
||||
b'`' => {
|
||||
let mut j = i + 1;
|
||||
loop {
|
||||
match s.get(j) {
|
||||
None => break 'scan,
|
||||
Some(b'\\') => j += 2,
|
||||
Some(b'`') => break,
|
||||
Some(_) => j += 1,
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
Js::Other
|
||||
}
|
||||
b'/' if i >= divides_until && regex_may_follow(js, &out) => {
|
||||
// A regular expression ends at a `/` outside a class; a line break first means it
|
||||
// was a division after all.
|
||||
let (mut j, mut class) = (i + 1, false);
|
||||
let end = loop {
|
||||
match s.get(j) {
|
||||
None | Some(b'\n') => break None,
|
||||
Some(b'\\') => j += 2,
|
||||
Some(b'/') if !class => break Some(j),
|
||||
Some(b) => {
|
||||
class = match b {
|
||||
b'[' => true,
|
||||
b']' => false,
|
||||
_ => class,
|
||||
};
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
match end {
|
||||
Some(e) => {
|
||||
i = e + 1;
|
||||
while s.get(i).is_some_and(|b| word(*b)) {
|
||||
i += 1;
|
||||
}
|
||||
Js::Other
|
||||
}
|
||||
None => {
|
||||
divides_until = j;
|
||||
i += 1;
|
||||
Js::Punct('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if c.is_ascii_digit() => {
|
||||
while s.get(i).is_some_and(|b| word(*b) || *b == b'.') {
|
||||
i += 1;
|
||||
}
|
||||
Js::Other
|
||||
}
|
||||
_ if word(c) => {
|
||||
while s.get(i).is_some_and(|b| word(*b)) {
|
||||
i += 1;
|
||||
}
|
||||
Js::Ident
|
||||
}
|
||||
_ => {
|
||||
i += 1;
|
||||
Js::Punct(char::from(c))
|
||||
}
|
||||
};
|
||||
out.push((start..i, token));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening
|
||||
/// bracket or a keyword such as `return`, not after a value.
|
||||
fn regex_may_follow(js: &str, tokens: &[(Range<usize>, Js)]) -> bool {
|
||||
match tokens.last() {
|
||||
None => true,
|
||||
Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)),
|
||||
Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'),
|
||||
Some((_, Js::Str(_) | Js::Other)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A place where a Calculate script names a field.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Site {
|
||||
/// A string literal holding one name: `getField("a")`, or an item of an
|
||||
/// `AFSimple_Calculate` list.
|
||||
Literal(String),
|
||||
/// An `AFSimple_Calculate` list written as one string of comma-separated names.
|
||||
Commas(String),
|
||||
/// A name in the simplified field notation of a `BVCALC` comment.
|
||||
Notation(String),
|
||||
}
|
||||
|
||||
impl Site {
|
||||
fn names(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
|
||||
Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The site written again with field `old` named `new`, or `None` when it doesn't name `old`.
|
||||
fn renamed(&self, old: &str, new: &str) -> Option<String> {
|
||||
match self {
|
||||
Site::Literal(name) => (name == old).then(|| js_str(new)),
|
||||
Site::Notation(name) => (name == old).then(|| notation_name(new)),
|
||||
Site::Commas(list) => {
|
||||
let parts: Vec<&str> = list.split(',').collect();
|
||||
parts.iter().any(|p| p.trim() == old).then(|| {
|
||||
// The spaces around each name stay as they were.
|
||||
let parts: Vec<String> = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect();
|
||||
js_str(&parts.join(","))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a Calculate script names fields, in order: the simplified field notation between
|
||||
/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), the field list of
|
||||
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
|
||||
/// A name built at run time (`getField("row" + i)`) is not a site.
|
||||
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
|
||||
let mut sites = Vec::new();
|
||||
if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC"))
|
||||
&& let Some(expr) = js.get(a + 6..b)
|
||||
{
|
||||
for (range, token) in notation_tokens(expr) {
|
||||
if let Notation::Name(name) = token {
|
||||
sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let tokens = js_tokens(js);
|
||||
let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word));
|
||||
let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c));
|
||||
let string = |k: usize| match tokens.get(k) {
|
||||
Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
|
||||
_ => None,
|
||||
};
|
||||
for k in 0..tokens.len() {
|
||||
if ident(k, "getField")
|
||||
&& punct(k + 1, '(')
|
||||
&& punct(k + 3, ')')
|
||||
&& let Some((range, name)) = string(k + 2)
|
||||
{
|
||||
sites.push((range, Site::Literal(name)));
|
||||
}
|
||||
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
|
||||
continue;
|
||||
}
|
||||
if let Some((range, list)) = string(k + 4) {
|
||||
sites.push((range, Site::Commas(list)));
|
||||
continue;
|
||||
}
|
||||
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
|
||||
// Nothing else is read, so no token is read for two calls.
|
||||
let first = if punct(k + 4, '[') {
|
||||
k + 5
|
||||
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
|
||||
k + 7
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
for (range, token) in tokens.iter().skip(first) {
|
||||
match token {
|
||||
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
|
||||
Js::Punct(',') => {}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
sites.sort_by_key(|(range, _)| range.start);
|
||||
sites
|
||||
}
|
||||
|
||||
/// A Calculate script with field `old` named `new` everywhere it reads it (see
|
||||
/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also
|
||||
/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end
|
||||
/// the notation early), so a script is never left meaning something else.
|
||||
pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option<String> {
|
||||
let sites = calculation_sites(js);
|
||||
let edits: Vec<(Range<usize>, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect();
|
||||
if edits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let out = splice(js, edits);
|
||||
let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect();
|
||||
let written = calculation_sites(&out);
|
||||
let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect();
|
||||
(got == expected).then_some(out)
|
||||
}
|
||||
|
||||
// ── running them ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a
|
||||
@@ -960,77 +1205,107 @@ pub fn validate(v: &Validate, value: &str) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
|
||||
/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
|
||||
pub fn notation_js(expr: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let cs: Vec<char> = expr.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_whitespace() || "+-*/()".contains(c) {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
/// A token of simplified field notation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Notation {
|
||||
/// A number, as written.
|
||||
Number,
|
||||
/// `+ - * / ( )`.
|
||||
Op(char),
|
||||
/// A field name, its `\` escapes undone.
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates
|
||||
/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them.
|
||||
fn notation_tokens(src: &str) -> Vec<(Range<usize>, Notation)> {
|
||||
let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c);
|
||||
let mut out = Vec::new();
|
||||
let mut cs = src.char_indices().peekable();
|
||||
while let Some(&(start, c)) = cs.peek() {
|
||||
let mut end = start + c.len_utf8();
|
||||
if c.is_whitespace() {
|
||||
cs.next();
|
||||
} else if breaks(c) {
|
||||
cs.next();
|
||||
out.push((start..end, Notation::Op(c)));
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') {
|
||||
out.push(d);
|
||||
i += 1;
|
||||
while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') {
|
||||
end = i + d.len_utf8();
|
||||
}
|
||||
out.push((start..end, Notation::Number));
|
||||
} else {
|
||||
let mut name = String::new();
|
||||
while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) {
|
||||
// `\` escapes a space or an operator inside a name, as Acrobat writes them.
|
||||
while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) {
|
||||
end = i + n.len_utf8();
|
||||
if n == '\\'
|
||||
&& let Some(&next) = cs.get(i + 1)
|
||||
&& let Some((j, next)) = cs.next()
|
||||
{
|
||||
end = j + next.len_utf8();
|
||||
name.push(next);
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
name.push(n);
|
||||
}
|
||||
name.push(n);
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name)));
|
||||
out.push((start..end, Notation::Name(name)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names
|
||||
/// (`\` escapes spaces and operators in names, as Acrobat writes them).
|
||||
/// A field name as simplified field notation writes it: `\` before a space, an operator or a
|
||||
/// `\`, and before a first character that would start a number.
|
||||
fn notation_name(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, c) in name.chars().enumerate() {
|
||||
if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `src` with each range replaced. The ranges come in order and don't overlap; one that does
|
||||
/// is left out rather than written over another.
|
||||
fn splice(src: &str, edits: impl IntoIterator<Item = (Range<usize>, String)>) -> String {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut done = 0;
|
||||
for (range, with) in edits {
|
||||
let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue };
|
||||
out.push_str(kept);
|
||||
out.push_str(&with);
|
||||
done = range.end;
|
||||
}
|
||||
out.push_str(src.get(done..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
|
||||
/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
|
||||
pub fn notation_js(expr: &str) -> String {
|
||||
let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t {
|
||||
Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))),
|
||||
_ => None,
|
||||
});
|
||||
splice(expr, names)
|
||||
}
|
||||
|
||||
/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names.
|
||||
fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option<f64> {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum T {
|
||||
N(f64),
|
||||
Op(char),
|
||||
}
|
||||
let mut toks = Vec::new();
|
||||
let cs: Vec<char> = src.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < cs.len() {
|
||||
let c = cs[i];
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if "+-*/()".contains(c) {
|
||||
toks.push(T::Op(c));
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
let s = i;
|
||||
while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') {
|
||||
i += 1;
|
||||
}
|
||||
toks.push(T::N(cs[s..i].iter().collect::<String>().parse().ok()?));
|
||||
} else {
|
||||
let mut name = String::new();
|
||||
while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) {
|
||||
if cs[i] == '\\' && i + 1 < cs.len() {
|
||||
i += 1;
|
||||
}
|
||||
name.push(cs[i]);
|
||||
i += 1;
|
||||
}
|
||||
toks.push(T::N(value_of(&name)));
|
||||
}
|
||||
}
|
||||
let toks = notation_tokens(src)
|
||||
.into_iter()
|
||||
.map(|(range, t)| match t {
|
||||
Notation::Number => src.get(range)?.parse().ok().map(T::N),
|
||||
Notation::Op(c) => Some(T::Op(c)),
|
||||
Notation::Name(name) => Some(T::N(value_of(&name))),
|
||||
})
|
||||
.collect::<Option<Vec<T>>>()?;
|
||||
// Recursive descent, `d` levels deep (at most MAX_NESTING).
|
||||
fn expr(t: &[T], i: &mut usize, d: usize) -> Option<f64> {
|
||||
let mut v = term(t, i, d)?;
|
||||
@@ -1163,6 +1438,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_rewrites_only_where_a_calculation_reads_the_field() {
|
||||
let renamed = |js: &str| rename_in_calculation(js, "a", "qty");
|
||||
// The field lists Acrobat and other tools write for AFSimple_Calculate.
|
||||
assert_eq!(
|
||||
renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(),
|
||||
Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));")
|
||||
);
|
||||
assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);"));
|
||||
assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");"));
|
||||
assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it");
|
||||
// Notation: the expression in the comment and Acrobat's translation after it.
|
||||
let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap();
|
||||
assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2")));
|
||||
assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2")));
|
||||
// Scripts: a getField whose argument is one string literal. Comments, strings, template
|
||||
// literals, regular expressions and names built at run time are left as written.
|
||||
let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\
|
||||
if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;";
|
||||
assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1)));
|
||||
assert_eq!(renamed("event.value = getField(\"b\").value;"), None);
|
||||
// A new name is escaped as the script needs it.
|
||||
assert_eq!(
|
||||
rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(),
|
||||
Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;")
|
||||
);
|
||||
// A name that would end the notation early is not written in.
|
||||
assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None);
|
||||
// Unfinished scripts end their tokens, not the program.
|
||||
for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] {
|
||||
assert_eq!(renamed(js), None, "{js}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
|
||||
/// one another's field lists, don't make a rename read the rest of the script again for each.
|
||||
#[test]
|
||||
fn hostile_scripts_are_read_in_one_pass() {
|
||||
let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000));
|
||||
assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")")));
|
||||
let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000));
|
||||
assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000))));
|
||||
}
|
||||
|
||||
/// Notation is read through one lexer: field names with escapes, numbers and operators.
|
||||
#[test]
|
||||
fn notation_is_read_by_one_lexer() {
|
||||
let tokens: Vec<Notation> = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect();
|
||||
assert_eq!(
|
||||
tokens,
|
||||
[
|
||||
Notation::Op('('),
|
||||
Notation::Name("Unit Price".into()),
|
||||
Notation::Op('+'),
|
||||
Notation::Number,
|
||||
Notation::Op(')'),
|
||||
Notation::Op('*'),
|
||||
Notation::Name("qty_2\\".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)");
|
||||
let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 };
|
||||
assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75));
|
||||
assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one");
|
||||
assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Any field name written in notation reads back as that one name.
|
||||
#[test]
|
||||
fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") {
|
||||
let written = notation_name(&name);
|
||||
proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]);
|
||||
}
|
||||
|
||||
/// Renaming a field in a formula and back gives the formula as it was.
|
||||
#[test]
|
||||
fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) {
|
||||
proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC")));
|
||||
let names: Vec<String> = std::iter::once(old.clone()).chain(others).collect();
|
||||
let js = if sum {
|
||||
calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap()
|
||||
} else {
|
||||
calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" * "))).unwrap()
|
||||
};
|
||||
let renamed = rename_in_calculation(&js, &old, &new);
|
||||
proptest::prop_assert!(renamed.is_some(), "{js}");
|
||||
let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old));
|
||||
proptest::prop_assert_eq!(back, Some(js));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_format_like_acrobat() {
|
||||
let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p };
|
||||
|
||||
@@ -927,6 +927,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
new_name = format!("{prefix}{n}");
|
||||
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
|
||||
// Calculations read the field by its new name, unless another field keeps the old one.
|
||||
if all.iter().filter(|x| x.name == f.name).count() == 1 {
|
||||
rename_in_calculations(doc, &all, &f.name, &new_name)?;
|
||||
}
|
||||
}
|
||||
// The new default appearance when the size or the look changes.
|
||||
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
|
||||
@@ -1216,6 +1220,38 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
/// Name field `old` `new` in each field's Calculate script that reads it
|
||||
/// ([`crate::af::rename_in_calculation`]).
|
||||
fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> {
|
||||
let aa_of =
|
||||
|doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned());
|
||||
for f in all {
|
||||
// The script is the field's, or its only widget's when the field has none, as
|
||||
// `actions_of` reads it.
|
||||
let only_widget = || match f.widgets.as_slice() {
|
||||
[w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)),
|
||||
_ => None,
|
||||
};
|
||||
let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue };
|
||||
let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue };
|
||||
let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() {
|
||||
Some(Object::String(s)) => s.to_text(),
|
||||
// A stream that isn't UTF-8 reads with replacement characters, which must not be
|
||||
// written back in place of what it holds.
|
||||
Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) {
|
||||
Some(js) => js,
|
||||
None => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue };
|
||||
action.set(b"JS".to_vec(), PdfString::text(&js));
|
||||
aa.set(b"C".to_vec(), Object::Dict(action));
|
||||
doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a field: its widgets leave their pages and the field leaves the form.
|
||||
pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
|
||||
@@ -970,6 +970,17 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// The fields whose calculation differs between two readings of a form, matched by field object
|
||||
/// and named as in `after`: the formulas an edit rewrote, such as a rename's.
|
||||
pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec<String> {
|
||||
let before: std::collections::HashMap<ObjRef, &af::Actions> = before.iter().map(|f| (f.obj, &f.actions)).collect();
|
||||
after
|
||||
.iter()
|
||||
.filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate))
|
||||
.map(|f| f.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
|
||||
Err(FormError::Invalid(m.into()))
|
||||
}
|
||||
|
||||
@@ -1048,6 +1048,84 @@ fn formats_validation_and_calculations_run_like_acrobat() {
|
||||
assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None);
|
||||
}
|
||||
|
||||
/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation
|
||||
/// read it as 0 and a script read a field that no longer existed.
|
||||
#[test]
|
||||
fn renaming_a_field_renames_it_in_the_calculations_that_read_it() {
|
||||
use crate::af::{CalcOp, Calculate};
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
for (i, name) in ["a", "b", "sum", "product", "script"].into_iter().enumerate() {
|
||||
let y = 700.0 - 40.0 * i as f64;
|
||||
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
|
||||
}
|
||||
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
|
||||
set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] })).unwrap();
|
||||
set_props(&mut doc, "product", &calculate(Calculate::Notation("a * b + ab".into()))).unwrap();
|
||||
set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value + getField(\"b\").value;")).unwrap();
|
||||
set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap();
|
||||
let check = |doc: &Document| {
|
||||
let all = fields(doc);
|
||||
assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "b".into()] });
|
||||
assert_eq!(field(&all, "product").actions.calculate, Calculate::Notation("qty * b + ab".into()));
|
||||
let script = field(&all, "script").actions.scripts.calculate.clone();
|
||||
assert_eq!(script.as_deref(), Some("event.value = getField(\"qty\").value + getField(\"b\").value;"));
|
||||
};
|
||||
check(&doc);
|
||||
let mut doc = reopen(&doc);
|
||||
check(&doc);
|
||||
set_value(&mut doc, "qty", &FieldValue::Text("10".into())).unwrap();
|
||||
set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap();
|
||||
let all = fields(&doc);
|
||||
assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()]));
|
||||
}
|
||||
|
||||
/// The rename reaches a script stored as a stream, an indirect action and the only widget's
|
||||
/// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has.
|
||||
#[test]
|
||||
fn a_rename_reaches_every_stored_form_of_a_calculation() {
|
||||
let widget = |name: &str, y: u32, rest: &str| {
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20)
|
||||
};
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 15 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
widget("a", 700, ""),
|
||||
widget("stream", 660, "/AA << /C 8 0 R >>"),
|
||||
widget("bytes", 620, "/AA << /C << /S /JavaScript /JS 10 0 R >> >>"),
|
||||
"<< /S /JavaScript /JS 9 0 R >>".into(),
|
||||
"<< /Length 34 >>\nstream\nevent.value = getField(\"a\").value;\nendstream".into(),
|
||||
"<< /Length 85 /Filter /ASCIIHexDecode >>\nstream\n2F2A20FF202A2F206576656E742E76616C7565203D206765744669656C6428226122292E76616C75653B>\nendstream".into(),
|
||||
widget("dup", 580, ""),
|
||||
widget("dup", 540, ""),
|
||||
widget("both", 500, "/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"dup\", \"a\"\\)\\);) >> >>"),
|
||||
"<< /FT /Tx /T (kid) /Kids [15 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 460 250 480] /P 3 0 R /AA << /C << /S /JavaScript /JS (event.value = getField\\('a'\\).value;) >> >> >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let calc = |doc: &Document, name: &str| {
|
||||
let all = fields(doc);
|
||||
let f = field(&all, name);
|
||||
(f.actions.calculate.clone(), f.actions.scripts.calculate.clone().unwrap_or_default())
|
||||
};
|
||||
assert!(calc(&doc, "stream").1.contains("getField(\"a\")"), "{:?}", calc(&doc, "stream"));
|
||||
set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(calc(&doc, "stream").1, "event.value = getField(\"qty\").value;");
|
||||
assert_eq!(calc(&doc, "kid").1, "event.value = getField(\"qty\").value;");
|
||||
assert!(calc(&doc, "bytes").1.contains("getField(\"a\")"), "a script that isn't UTF-8 is not written back");
|
||||
assert_eq!(calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] });
|
||||
let mut doc = doc;
|
||||
set_props(&mut doc, "dup", &FieldProps { name: Some("dup2".into()), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(
|
||||
calc(&doc, "both").0,
|
||||
af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] },
|
||||
"the other dup keeps the name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_fields_fit_their_box() {
|
||||
// #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's
|
||||
|
||||
@@ -1330,12 +1330,29 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if field_props_now
|
||||
&& let Some(d) = app.field_props.take()
|
||||
&& let Some(props) = d.props()
|
||||
&& app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
|
||||
&& let Some(i) = app.active
|
||||
{
|
||||
// Keep the (possibly renamed) field selected.
|
||||
let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
app.views[i].prepare.selected = Some((format!("{prefix}{}", d.name.trim()), d.widget));
|
||||
let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
|
||||
// A rename also renames the field in the formulas that read it.
|
||||
let before = props.name.as_ref().and_then(|_| form(app));
|
||||
if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
|
||||
&& let Some(i) = app.active
|
||||
{
|
||||
// Keep the (possibly renamed) field selected.
|
||||
let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
let name = format!("{prefix}{}", d.name.trim());
|
||||
if let (Some(before), Some(after)) = (before, form(app)) {
|
||||
let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
|
||||
if n > 0 {
|
||||
app.notify(crate::i18n::trn(
|
||||
crate::i18n::current(),
|
||||
n as u64,
|
||||
"{n} calculation now uses the new name.",
|
||||
"{n} calculations now use the new name.",
|
||||
));
|
||||
}
|
||||
}
|
||||
app.views[i].prepare.selected = Some((name, d.widget));
|
||||
}
|
||||
}
|
||||
if props_now && let Some(d) = app.comment_props.take() {
|
||||
let mut edits = crate::comment_props::edits(&d);
|
||||
|
||||
@@ -2362,6 +2362,7 @@ animation effects Off معطّلة
|
||||
no unused output name لا يوجد اسم ناتج غير مستخدم
|
||||
no output folder was chosen لم يتم اختيار مجلد للإخراج
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs تم تنزيل {n} من ملفات PDF|تم تنزيل ملف PDF واحد ({n})|تم تنزيل ملفي PDF ({n})|تم تنزيل {n} ملفات PDF|تم تنزيل {n} ملف PDF|تم تنزيل {n} من ملفات PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. تم تحديث {n} من العمليات الحسابية لتستخدم الاسم الجديد.|عملية حسابية واحدة ({n}) تستخدم الآن الاسم الجديد.|عمليتان حسابيتان ({n}) تستخدمان الآن الاسم الجديد.|{n} عمليات حسابية تستخدم الآن الاسم الجديد.|{n} عملية حسابية تستخدم الآن الاسم الجديد.|{n} من العمليات الحسابية تستخدم الآن الاسم الجديد.
|
||||
Scan مسح ضوئي
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق.
|
||||
Choose photo… اختيار صورة…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2304,6 +2304,7 @@ animation effects Off Изключени
|
||||
no unused output name няма свободно име за изходния файл
|
||||
no output folder was chosen не е избрана папка за изхода
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Изтеглен е {n} PDF|Изтеглени са {n} PDF файла
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} изчисление вече използва новото име.|{n} изчисления вече използват новото име.
|
||||
Scan Сканиране
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията.
|
||||
Choose photo… Избор на снимка…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2415,6 +2415,7 @@ animation effects Off Vypnuto
|
||||
no unused output name žádný volný název výstupního souboru
|
||||
no output folder was chosen nebyla vybrána výstupní složka
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Stažen {n} soubor PDF|Staženy {n} soubory PDF|Staženo {n} souborů PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} výpočet nyní používá nový název.|{n} výpočty nyní používají nový název.|{n} výpočtů nyní používá nový název.
|
||||
Scan Skenovat
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír.
|
||||
Choose photo… Vybrat fotografii…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2342,6 +2342,7 @@ animation effects Off Aus
|
||||
no unused output name kein freier Ausgabename
|
||||
no output folder was chosen kein Ausgabeordner gewählt
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF heruntergeladen|{n} PDFs heruntergeladen
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} Berechnung verwendet jetzt den neuen Namen.|{n} Berechnungen verwenden jetzt den neuen Namen.
|
||||
Scan Scannen
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier.
|
||||
Choose photo… Foto auswählen…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2338,6 +2338,7 @@ animation effects Off Ανενεργά
|
||||
no unused output name δεν υπάρχει διαθέσιμο όνομα εξόδου
|
||||
no output folder was chosen δεν επιλέχθηκε φάκελος εξόδου
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Λήφθηκε {n} PDF|Λήφθηκαν {n} PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} υπολογισμός χρησιμοποιεί πλέον το νέο όνομα.|{n} υπολογισμοί χρησιμοποιούν πλέον το νέο όνομα.
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list}
|
||||
Scan Σάρωση
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 477 and column 9.
|
@@ -2470,6 +2470,7 @@ animation effects Off Desactivados
|
||||
no unused output name no hay un nombre de salida libre
|
||||
no output folder was chosen no se eligió ninguna carpeta de salida
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Se descargó {n} PDF|Se descargaron {n} PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo usa ahora el nuevo nombre.|{n} cálculos usan ahora el nuevo nombre.
|
||||
Scan Escanear
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma en una hoja de papel blanco y hazle una foto con luz intensa y uniforme (usa el flash). Elige la foto o suéltala aquí: PdfCraft encuentra la firma y elimina el papel.
|
||||
Choose photo… Elegir foto…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -2341,6 +2341,7 @@ animation effects Off Désactivés
|
||||
no unused output name aucun nom de sortie libre
|
||||
no output folder was chosen aucun dossier de sortie choisi
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF téléchargé|{n} PDF téléchargés
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcul utilise désormais le nouveau nom.|{n} calculs utilisent désormais le nouveau nom.
|
||||
Scan Numériser
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier.
|
||||
Choose photo… Choisir une photo…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2340,6 +2340,7 @@ animation effects Off Ki
|
||||
no unused output name nincs szabad kimeneti név
|
||||
no output folder was chosen nincs kiválasztva kimeneti mappa
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF letöltve|{n} PDF letöltve
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} számítás mostantól az új nevet használja.|{n} számítás mostantól az új nevet használja.
|
||||
Scan Beolvasás
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt.
|
||||
Choose photo… Fénykép kiválasztása…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2350,6 +2350,7 @@ animation effects Off Disattivati
|
||||
no unused output name nessun nome di output libero
|
||||
no output folder was chosen nessuna cartella di output scelta
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Scaricato {n} PDF|Scaricati {n} PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcolo ora usa il nuovo nome.|{n} calcoli ora usano il nuovo nome.
|
||||
Scan Scansiona
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta.
|
||||
Choose photo… Scegli foto…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2341,6 +2341,7 @@ animation effects Off オフ
|
||||
no unused output name 使用可能な出力名がありません
|
||||
no output folder was chosen 出力フォルダーが選択されていません
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} 個の PDF をダウンロードしました
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 件の計算式を新しい名前に更新しました。
|
||||
Scan スキャン
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。
|
||||
Choose photo… 写真を選択…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2328,6 +2328,7 @@ animation effects Off Өшірулі
|
||||
no unused output name бос шығыс атауы жоқ
|
||||
no output folder was chosen шығыс қалтасы таңдалмады
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF жүктелді|{n} PDF жүктелді
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} есептеу енді жаңа атауды пайдаланады.|{n} есептеу енді жаңа атауды пайдаланады.
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}
|
||||
Scan Сканерлеу
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 826 and column 9.
|
@@ -2598,6 +2598,7 @@ certificate SHA-256 digest SHA-256 santrauka
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} lauko pavadinimas sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadintas: {list}|{n} laukų pavadinimai sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinti: {list}|{n} laukų pavadinimų sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinta: {list}
|
||||
@plural {n} object selected|{n} objects selected Pažymėtas {n} objektas|Pažymėti {n} objektai|Pažymėta {n} objektų
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Atsisiųstas {n} PDF failas|Atsisiųsti {n} PDF failai|Atsisiųsta {n} PDF failų
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} skaičiavimas dabar naudoja naują pavadinimą.|{n} skaičiavimai dabar naudoja naują pavadinimą.|{n} skaičiavimų dabar naudoja naują pavadinimą.
|
||||
key Ctrl Ctrl
|
||||
key Shift Shift
|
||||
key Delete Delete
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 404 and column 9.
|
@@ -2309,6 +2309,7 @@ animation effects Off Wyłączone
|
||||
no unused output name brak wolnej nazwy pliku wyjściowego
|
||||
no output folder was chosen nie wybrano folderu wyjściowego
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Pobrano {n} plik PDF|Pobrano {n} pliki PDF|Pobrano {n} plików PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} obliczenie używa teraz nowej nazwy.|{n} obliczenia używają teraz nowej nazwy.|{n} obliczeń używa teraz nowej nazwy.
|
||||
Scan Skanuj
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier.
|
||||
Choose photo… Wybierz zdjęcie…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2355,6 +2355,7 @@ animation effects Off Desativados
|
||||
no unused output name nenhum nome de saída disponível
|
||||
no output folder was chosen nenhuma pasta de saída foi escolhida
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF baixado|{n} PDFs baixados
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo agora usa o novo nome.|{n} cálculos agora usam o novo nome.
|
||||
Scan Digitalizar
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel.
|
||||
Choose photo… Escolher foto…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2254,6 +2254,7 @@ animation effects Off Выкл
|
||||
no unused output name нет свободного имени для выходного файла
|
||||
no output folder was chosen не выбрана папка для вывода
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Скачан {n} PDF-файл|Скачано {n} PDF-файла|Скачано {n} PDF-файлов
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} вычисление теперь использует новое имя.|{n} вычисления теперь используют новое имя.|{n} вычислений теперь используют новое имя.
|
||||
Scan Сканировать
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу.
|
||||
Choose photo… Выбрать фото…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2345,6 +2345,7 @@ animation effects Off Izklopljeni
|
||||
no unused output name ni prostega izhodnega imena
|
||||
no output folder was chosen izhodna mapa ni bila izbrana
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Prenesen {n} PDF|Prenesena {n} PDF-ja|Preneseni {n} PDF-ji|Prenesenih {n} PDF-jev
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} izračun zdaj uporablja novo ime.|{n} izračuna zdaj uporabljata novo ime.|{n} izračuni zdaj uporabljajo novo ime.|{n} izračunov zdaj uporablja novo ime.
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list}
|
||||
Scan Skeniraj
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2242,6 +2242,7 @@ animation effects Off ఆఫ్
|
||||
no unused output name ఉపయోగించని అవుట్పుట్ పేరు లేదు
|
||||
no output folder was chosen అవుట్పుట్ ఫోల్డర్ ఎంచుకోలేదు
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF డౌన్లోడ్ చేయబడింది|{n} PDFలు డౌన్లోడ్ చేయబడ్డాయి
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} గణన ఇప్పుడు కొత్త పేరును ఉపయోగిస్తుంది.|{n} గణనలు ఇప్పుడు కొత్త పేరును ఉపయోగిస్తాయి.
|
||||
Scan స్కాన్
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది.
|
||||
Choose photo… ఫోటో ఎంచుకోండి…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -2326,6 +2326,7 @@ animation effects Off Kapalı
|
||||
no unused output name kullanılmayan çıktı adı yok
|
||||
no output folder was chosen çıktı klasörü seçilmedi
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF indirildi
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} hesaplama artık yeni adı kullanıyor.
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list}
|
||||
Scan Tara
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 825 and column 9.
|
@@ -2363,6 +2363,7 @@ animation effects Off Вимкнено
|
||||
no unused output name немає вільної назви для вихідного файлу
|
||||
no output folder was chosen не вибрано теку для виводу
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Завантажено {n} PDF-файл|Завантажено {n} PDF-файли|Завантажено {n} PDF-файлів
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} обчислення тепер використовує нову назву.|{n} обчислення тепер використовують нову назву.|{n} обчислень тепер використовують нову назву.
|
||||
Scan Сканувати
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір.
|
||||
Choose photo… Вибрати фото…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2588,6 +2588,7 @@ certificate SHA-256 digest Giá trị băm SHA-256
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} tên trường trùng với các tệp trước và sẽ được đổi tên: {list}
|
||||
@plural {n} object selected|{n} objects selected Đã chọn {n} đối tượng
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Đã tải xuống {n} PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} phép tính giờ dùng tên mới.
|
||||
key Ctrl Ctrl
|
||||
key Shift Shift
|
||||
key Delete Delete
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 491 and column 12.
|
@@ -2305,6 +2305,7 @@ animation effects Off 关
|
||||
no unused output name 没有可用的输出文件名
|
||||
no output folder was chosen 未选择输出文件夹
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs 已下载 {n} 个 PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 个计算已改用新名称。
|
||||
Scan 扫描
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。
|
||||
Choose photo… 选择照片…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2204,6 +2204,7 @@ animation effects Off 關
|
||||
no unused output name 沒有可用的輸出檔名
|
||||
no output folder was chosen 未選擇輸出資料夾
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs 已下載 {n} 個 PDF
|
||||
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 個計算已改用新名稱。
|
||||
Scan 掃描
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。
|
||||
Choose photo… 選擇相片…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -147,6 +147,45 @@ fn format_validate_and_calculate_tabs() {
|
||||
assert_eq!(f.actions.calculate, Calculate::Notation("name".into()));
|
||||
}
|
||||
|
||||
/// Renaming a field in Field Properties renames it in the formulas that read it, in the same undo
|
||||
/// step, and says how many changed.
|
||||
#[test]
|
||||
fn renaming_a_field_says_how_many_calculations_now_use_the_new_name() {
|
||||
use pdfcraft_engine::form_scripts::Calculate;
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("form.prepare");
|
||||
h.run_steps(2);
|
||||
let rename = |h: &mut Harness<'static, PdfCraftApp>, from: &str, to: &str| {
|
||||
h.state_mut().open_field_props(from, 0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().field_props.as_mut().expect("open").name = to.into();
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
};
|
||||
h.state_mut().open_field_props("city", 0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().field_props.as_mut().expect("open").calculate = Calculate::Notation("name * 2".into());
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let calculation = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().actions.calculate.clone()
|
||||
};
|
||||
h.state_mut().toast = None;
|
||||
rename(&mut h, "name", "full_name");
|
||||
assert_eq!(calculation(&h), Calculate::Notation("full_name * 2".into()));
|
||||
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("1 calculation now uses the new name."));
|
||||
h.state_mut().toast = None;
|
||||
rename(&mut h, "city", "town");
|
||||
assert!(names(&h).contains(&"town".to_string()));
|
||||
assert_eq!(h.state().toast, None, "no formula reads the calculated field itself");
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
assert!(names(&h).contains(&"name".to_string()));
|
||||
assert_eq!(calculation(&h), Calculate::Notation("name * 2".into()), "undone with the rename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_tab_restyles_the_field() {
|
||||
use pdfcraft_engine::{BorderStyle, FieldFont};
|
||||
|
||||
+2
-1
@@ -143,7 +143,7 @@ total matches the parity estimate.
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
@@ -202,6 +202,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
|
||||
|
||||
@@ -4968,8 +4968,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.field.properties"]
|
||||
tools = ["form_set_props", "form_fill"]
|
||||
evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
|
||||
notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending"
|
||||
evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name"]
|
||||
notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. Whether Acrobat does this is not observed."
|
||||
|
||||
[[feature]]
|
||||
id = "form.props-selection-change"
|
||||
|
||||
Reference in New Issue
Block a user