M6: a radio number is compared with decimal export values as a number first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 20:58:42 +08:00
1 parent edd16f9f8a
commit 9e51a1f4a6
3 files changed
+61 -3

No files matched your search

+10 -3
View File
@@ -238,10 +238,17 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
// button, not the second one's state. A value matching neither is refused when written,
// with the options listed.
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
// A number is the same option written without quotes (`1` for the export value `1`).
// A number is the same option written without quotes (`1` for the export value `1`). It
// is compared as a number with the export values first, so `1.0` names the button
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
// `/1`; then as text, as a string would be.
(FormFieldKind::Radio, Value::Number(n)) => {
let s = number_option(n);
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
FieldValue::Radio(Some(state.to_owned()))
} else {
let s = number_option(n);
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
}
}
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
+38
View File
@@ -2770,6 +2770,44 @@ fn form_fill_reads_a_radio_number_by_its_integer_spelling() {
}
}
/// Export values written as decimals (`/Opt [(1.0) (2.0)]`, states `/0 /1`): a number is compared
/// with them as a number first, so `1`, `1.0` and `1e0` all pick the first button and `2.0` the
/// second, where `1.0` had become `"1"`, matched no export value and picked the button whose state
/// is `/1`. A string is still matched as written, and a number no export value equals is refused.
#[test]
fn form_fill_matches_a_radio_number_to_decimal_export_values() {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
"<< /FT /Btn /T (rate) /Ff 49152 /Opt [(1.0) (2.0)] /V /Off /Kids [6 0 R 7 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>
stream
endstream",
];
let dir = workdir("radio-decimal-exports");
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"rate": {lit}}}}}"#)).unwrap() };
let on = |a: &Automation| -> Vec<usize> {
let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone();
f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect()
};
for (lit, button) in [("1.0", 0), ("2.0", 1), ("1", 0), ("2", 1), ("1e0", 0), ("\"1.0\"", 0), ("\"2.0\"", 1)] {
// From the other button, so a fill that does nothing would show.
ok(&mut a, "form_fill", fill(if button == 0 { "2.0" } else { "1.0" }));
ok(&mut a, "form_fill", fill(lit));
assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1);
}
let e = err(&mut a, "form_fill", fill("3")).to_string();
assert!(e.contains("has no option"), "{e}");
}
/// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and
/// resets through the tools, and the leniency shows up as a repair note.
#[test]
+13
View File
@@ -229,6 +229,19 @@ impl Field {
by_export.or_else(|| self.state_for(value))
}
/// The on-state name of the button whose export value is the number `value` (`/Opt [(1.0)
/// (2.0)]`: `1` and `1.0` both name the first button), compared as numbers, so the spelling a
/// caller writes a number in doesn't matter. `None` when no export value is that number, or
/// the field has no `/Opt`.
pub fn state_for_number(&self, value: f64) -> Option<&str> {
if !value.is_finite() {
return None;
}
let exports = self.button_exports()?;
let i = exports.iter().position(|(e, _)| e.trim().parse::<f64>().is_ok_and(|x| x == value))?;
self.widgets.get(i)?.on_state.as_deref()
}
/// The export value for an on-state name such as the field's value; the name itself when the
/// field has no `/Opt`.
pub fn export_for_state<'a>(&'a self, state: &'a str) -> &'a str {