mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M6: a radio option given as a JSON number names the option its integer spelling exports, and the export-value-first precedence is stated
form_fill turned a JSON number for a radio group into text with
Number::to_string, which spells a float with a fraction part: 1.0, 1e0
and 0.1e1 became "1.0", and serde_json parses -0 as the float -0.0
("-0.0"). None names the option "1". number_option now spells a whole
number below 2^53 as that integer (1.0 -> "1", -0 -> "0"); integers keep
their exact spelling; any other number keeps JSON's spelling (1.5,
1e300), so it names only an option exported as exactly that text, and is
otherwise refused with the options listed.
The precedence when an on-state name equals another button's export
value (export value first, then on-state names; ISO 32000-2 Table 230
lets state names be positions) is now stated in state_for_export, at the
call site and in form_fill's tool description, with the number rules.
Test (fails with Number::to_string restored): the ambiguous pair (the
first button exports "1", the second's state is /1) filled with 1, 1.0,
1e0, 0.1e1, "1", -0, 2e0, 2.000 and 3.0 selects the right button; "1.0",
1.5, -1, 1e300, u64::MAX and 9007199254740993.0 are refused with the
options listed and the selection kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
1 parent
5d2332107a
commit
c42851da1e
4 files changed
+70
-4
No files matched your search
@@ -208,17 +208,39 @@ fn kind_name(k: FormFieldKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
|
||||
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
|
||||
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
|
||||
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
|
||||
/// by accident.
|
||||
fn number_option(n: &serde_json::Number) -> String {
|
||||
// Integers as parsed (u64, i64) print exactly.
|
||||
if n.is_u64() || n.is_i64() {
|
||||
return n.to_string();
|
||||
}
|
||||
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
|
||||
match n.as_f64() {
|
||||
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
|
||||
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
|
||||
_ => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
|
||||
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
|
||||
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
|
||||
Ok(match (f.kind, v) {
|
||||
(FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b),
|
||||
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
|
||||
// An option as `form_fields` lists it (an export value) names its button's on state.
|
||||
// An option as `form_fields` lists it (an export value) names its button's on state. The
|
||||
// export values are matched first and the on-state names after them
|
||||
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
|
||||
// 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`).
|
||||
(FormFieldKind::Radio, Value::Number(n)) => {
|
||||
let s = n.to_string();
|
||||
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(
|
||||
|
||||
@@ -397,7 +397,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"form_fill",
|
||||
"Fill in form fields",
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
|
||||
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
|
||||
|
||||
@@ -2669,6 +2669,48 @@ fn form_fill_picks_a_radio_option_by_its_export_value() {
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1");
|
||||
}
|
||||
|
||||
/// The ambiguous pair: the second button's on state is named `1` and the first button exports
|
||||
/// `1`. The export value wins, however the number is written in JSON (`1`, `1.0`, `1e0`, `0.1e1`);
|
||||
/// `-0` is `0`, the first button's state. A string is matched as written, so `"1.0"` names no
|
||||
/// option, and neither does a number that is not whole (`1.5`) or past 2^53 (`1e300`): each is
|
||||
/// refused with the options listed and the selection kept.
|
||||
#[test]
|
||||
fn form_fill_reads_a_radio_number_by_its_integer_spelling() {
|
||||
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 8 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>",
|
||||
"<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream",
|
||||
];
|
||||
let dir = workdir("radio-number-spelling");
|
||||
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();
|
||||
// The values as JSON text, so each number keeps the spelling a client sends.
|
||||
let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"title": {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 [("2", 1), ("1", 0), ("3.0", 2), ("1.0", 0), ("2e0", 1), ("1e0", 0), ("0.1e1", 0), ("-0", 0), ("\"1\"", 0), ("2.000", 1)] {
|
||||
// From another button, so a fill that does nothing would show.
|
||||
ok(&mut a, "form_fill", fill(if button == 2 { "1" } else { "3" }));
|
||||
ok(&mut a, "form_fill", fill(lit));
|
||||
assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1);
|
||||
}
|
||||
ok(&mut a, "form_fill", fill("2"));
|
||||
for lit in ["\"1.0\"", "1.5", "-1", "1e300", "18446744073709551615", "9007199254740993.0"] {
|
||||
let e = err(&mut a, "form_fill", fill(lit)).to_string();
|
||||
assert!(e.contains("has no option") && e.contains("options: 1, 2, 3"), "{lit}: {e}");
|
||||
assert_eq!(on(&a), [1], "{lit} keeps the selection");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -218,7 +218,9 @@ impl Field {
|
||||
/// options [`Field::export_of`] lists. A field with one `/Opt` entry per widget matches those
|
||||
/// entries first and its on-state names only after them: the names may be positions (`/0`,
|
||||
/// `/1`; ISO 32000-2 Table 230), so the export value `1` is the first widget, not the second
|
||||
/// one's state `/1`. Elsewhere this is [`Field::state_for`].
|
||||
/// one's state `/1`. The order is (1) an `/Opt` export value, (2) an on-state name: when one
|
||||
/// button's on state is named `1` and another button exports `1`, `1` selects the one that
|
||||
/// exports it. Elsewhere this is [`Field::state_for`].
|
||||
pub fn state_for_export(&self, value: &str) -> Option<&str> {
|
||||
let by_export = self.button_exports().and_then(|exports| {
|
||||
let i = exports.iter().position(|(e, _)| e == value)?;
|
||||
|
||||
Reference in new issue
Block a user