mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
1 parent
edd16f9f8a
commit
9e51a1f4a6
3 files changed
+61
-3
No files matched your search
@@ -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<_>>()?,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user