M6: calculation checks: formulas by field object, whole AFSimple lists only, no all-clear beside a broken formula

- CalculatedField carries the field object: form_calculation_order and
  the Calculation order page find each row's formula and value by it,
  so two calculated fields sharing a name (a malformed form) each show
  their own.
- An AFSimple_Calculate list counts as written out only when it ends
  the call: "row" + suffix and [...].concat(more) are names built at
  run time, and an array item counts only when it ends at a comma or
  the list's end, so ["row" + i] no longer reads, or renames, "row".
- After a reorder, Undo is offered on the missing-field card instead of
  bringing back the all-clear beside it.

From CodeRabbit's review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
This commit is contained in:
Grzegorz Martens
2026-10-29 20:48:37 +01:00
co-authored by Claude Opus 5.5
parent feefeb8c95
commit 6a5020760e
9 changed files with 163 additions and 23 deletions
+3 -5
View File
@@ -667,11 +667,9 @@ impl Automation {
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
CalculationIssue::BuiltNames => json!({ "issue": "built_names" }),
};
// By field object, not name: two calculated fields can share a name in a malformed form.
let doc = self.doc(a)?;
let mut by_name: std::collections::HashMap<&str, &FormField> = std::collections::HashMap::with_capacity(doc.form.len());
for f in doc.form.iter() {
by_name.entry(f.name.as_str()).or_insert(f);
}
let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect();
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
out["fields"] = order
.fields
@@ -679,7 +677,7 @@ impl Automation {
.map(|f| {
json!({
"name": f.name,
"formula": by_name.get(f.name.as_str()).and_then(|x| calculate_json(x)),
"formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)),
"reads": f.reads,
"listed": f.listed,
"issues": f.issues.iter().map(issue).collect::<Vec<_>>(),
+37
View File
@@ -5058,6 +5058,43 @@ fn the_calculation_order_is_read_fixed_and_set() {
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
}
/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`.
fn two_calculations_with_one_name() -> Vec<u8> {
let widget = |name: &str, y: u32, reads: &str| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
y + 20
)
};
pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>",
&widget("dup", 700, "x"),
&widget("dup", 660, "src"),
&widget("src", 620, "x"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>",
])
}
/// Each row of the calculation order gets its own field's formula, even where two fields share a
/// name (a malformed form): the first dup sums src, the second x.
#[test]
fn the_calculation_order_gives_each_field_its_own_formula() {
let dir = workdir("calcdup");
std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"]));
let formulas: Vec<Value> = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
assert_eq!(
formulas,
[json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })]
);
}
/// The calculation order through the tool shows each formula, in form_set_props's shape or as a
/// script, and flags a read of a field the form doesn't have and a script that builds the names
/// it reads; form_fields shows a custom script too.
+2 -1
View File
@@ -87,7 +87,8 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
left alone. `calculations_changed` compares two readings of a form to say which formulas an
edit rewrote.
- The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the
order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`,
order `recalculate` runs them, each with its field object (two can share a name in a malformed
form), with the fields each formula reads (`Calculate::reads`,
`af::calculation_reads`; names built at run time aren't known) and what is wrong with the
order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO`
entries that calculate nothing. Two issues are about the formula itself: it reads a field the
+24 -8
View File
@@ -277,12 +277,18 @@ fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
sites.extend(built(k));
continue;
}
// One string of comma-separated names, when it is the whole list (not `"row" + suffix`).
if let Some((range, list)) = string(k + 4) {
sites.push((range, Site::Commas(list)));
if punct(k + 5, ')') {
sites.push((range, Site::Commas(list)));
} else {
sites.extend(built(k));
}
continue;
}
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string,
// which has to close the list for the list to be written out. Nothing else is read, so no
// `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has
// to end the call. An item that is more than a string (`"row" + i`), or anything after
// the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no
// token is read for two calls.
let (first, close) = if punct(k + 4, '[') {
(k + 5, ']')
@@ -292,11 +298,21 @@ fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
sites.extend(built(k));
continue;
};
for (range, token) in tokens.iter().skip(first) {
match token {
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
Js::Punct(',') => {}
Js::Punct(c) if *c == close => break,
let mut j = first;
loop {
match tokens.get(j) {
// An item ends at a comma or the list's end, or where a script cut short ends.
Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => {
sites.push((range.clone(), Site::Literal(name.clone())));
j += 1;
}
Some((_, Js::Punct(','))) => j += 1,
Some((_, Js::Punct(c))) if *c == close => {
if !punct(j + 1, ')') {
sites.extend(built(k));
}
break;
}
_ => {
sites.extend(built(k));
break;
+14
View File
@@ -168,9 +168,23 @@ fn names_built_at_run_time_are_noticed() {
"AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));",
"AFSimple_Calculate(op, new Array(\"a\"));",
"AFSimple_Calculate(\"SUM\", \"row\" + suffix);",
"AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));",
"AFSimple_Calculate(\"SUM\", [\"row\" + i]);",
] {
assert!(reads_built_names(js), "{js}");
}
// A string that is only the start of a name or a list isn't read, or renamed, as one.
for (js, reads) in [
("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]),
] {
assert_eq!(parse_calculate(js), None, "{js} is a script");
assert_eq!(calculation_reads(js), reads, "{js}");
assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}");
}
for js in [
"event.value = getField(\"a\").value + getField('b').value;",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));",
+4 -1
View File
@@ -17,6 +17,9 @@ const MAX_LINKS: usize = 1 << 20;
pub struct CalculatedField {
/// The field's full name.
pub name: String,
/// The field object. Two calculated fields can share a name in a malformed form; this tells
/// them apart.
pub obj: ObjRef,
/// The fields its formula reads, each once, as the formula names them: a name also stands for
/// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here.
pub reads: Vec<String>,
@@ -184,7 +187,7 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) {
issues.push(CalculationIssue::BuiltNames);
}
out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues });
out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues });
}
let fixed = fixed_order(&links, &component);
CalculationOrder { fields: out, extra, fixed }
+6
View File
@@ -1814,6 +1814,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() {
};
let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok);
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]);
// Each row is its own field object, so a caller can find the right formula for each dup.
let all = fields(&doc);
for c in calculation_order(&doc, &all).fields {
let f = all.iter().find(|f| f.obj == c.obj).unwrap();
assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone()));
}
let current: Vec<String> = ["dup", "src", "dup"].map(String::from).into();
set_calculation_order(&mut doc, &current).unwrap();
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged");
+12 -8
View File
@@ -139,16 +139,14 @@ fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(200).collect()
}
/// Each calculated field's value, formula and standing, in the order's order.
/// Each calculated field's value, formula and standing, in the order's order. Fields are found by
/// their object, not their name: two calculated fields can share a name in a malformed form.
fn rows(calculated: &[CalculatedField], form: &[FormField]) -> Vec<Row> {
let mut by_name: HashMap<&str, &FormField> = HashMap::with_capacity(form.len());
for f in form {
by_name.entry(f.name.as_str()).or_insert(f);
}
let by_obj: HashMap<_, &FormField> = form.iter().map(|f| (f.obj, f)).collect();
calculated
.iter()
.map(|c| {
let field = by_name.get(c.name.as_str());
let field = by_obj.get(&c.obj);
let value = match field {
Some(f) if f.kind == FormFieldKind::Text => f.value.first().map(|v| format_value(&f.actions.format, v)).unwrap_or_default(),
Some(f) => f.display_value(),
@@ -451,6 +449,11 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
{
act = Some(Act::EditFormula((*first).to_owned()));
}
// The all-clear, which carries Undo after a reorder, stays hidden while a formula is
// broken, so Undo is offered here.
if undo && text_button(ui, tl!("Undo"), t) {
act = Some(Act::Undo);
}
});
ui.add_space(6.0);
}
@@ -503,7 +506,7 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
ui.add(egui::Label::new(egui::RichText::new(tl!("Moves only what must move")).font(theme::regular(11.5)).color(t.text_muted)).wrap());
});
});
} else if missing.is_empty() || undo {
} else if missing.is_empty() {
let (fill, edge, colour) = pal.good;
card(ui, fill, edge, |ui| {
let said = if loops.is_empty() {
@@ -965,7 +968,8 @@ mod tests {
use super::*;
fn field(name: &str, reads: &[&str], issues: Vec<CalculationIssue>) -> CalculatedField {
CalculatedField { name: name.into(), reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues }
let obj = pdfcraft_cos::ObjRef { num: 0, generation: 0 };
CalculatedField { name: name.into(), obj, reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues }
}
#[test]
+61
View File
@@ -1354,6 +1354,56 @@ fn deleting_two_radio_buttons_of_a_group_at_once() {
assert!(!names(&h).iter().any(|n| n == "shirt"));
}
/// Two calculated fields that share a name (a malformed form) each show their own formula: the
/// first dup sums src, the second x.
#[test]
fn fields_sharing_a_name_each_show_their_own_formula() {
let widget = |name: &str, y: u32, reads: &str| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
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 8 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>".into(),
widget("dup", 700, "x"),
widget("dup", 660, "src"),
widget("src", 620, "x"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>".into(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("dup.pdf", None, bytes.clone()).unwrap();
app
});
h.run_steps(4);
h.state_mut().execute("form.prepare");
h.run_steps(3);
h.get_by_label("Calculation order").click();
h.run_steps(3);
for (row, formula) in [("1. dup", "= sum(src)"), ("3. dup", "= sum(x)")] {
h.get_by_label_contains(row).hover();
h.run_steps(30);
assert!(h.query_by_label_contains(formula).is_some(), "{row} shows {formula}");
}
}
/// A formula that reads a field the form doesn't have gets a card of its own, in place of "every
/// field calculates after the fields it reads", and its link opens that formula. A script that
/// builds the names it reads gets a note.
@@ -1383,6 +1433,17 @@ fn a_formula_reading_a_missing_field_has_its_own_card() {
assert!(h.query_by_label("1 formula reads a field the form doesn't have").is_some());
assert!(h.query_by_label("sub reads discount, which the form doesn't have.").is_some());
assert!(h.query_by_label("Every field calculates after the fields it reads.").is_none(), "no all-clear while a formula is broken");
// Reordered, the order can be undone from the missing-field card; the all-clear still stays away.
h.state_mut().apply_edit(Edit::SetCalculationOrder { names: vec!["rows".into(), "sub".into()] });
h.run_steps(3);
assert!(h.query_by_label("Every field calculates after the fields it reads.").is_none(), "still no all-clear");
h.get_by_label("Undo").click();
h.run_steps(3);
let order: Vec<String> = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().calculation_order().fields.into_iter().map(|f| f.name).collect()
};
assert_eq!(order, ["sub", "rows"], "Undo put the order back");
h.get_by_label("Edit sub's formula").click();
h.run_steps(3);
let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab));