Add JavaScript push-button caption methods (#5505)

* Add push-button caption script methods

* Apply scripted caption with other field changes

* Complete caption field property initializers

* Apply scripted captions to every button widget

* Format caption regression test

* Cover caption getter errors and redo state

* Assert button caption undo state

* Reject button caption writes during format events

* Test empty push-button captions

* Validate push-button caption updates

* Keep no-widget caption test data alive

* Check caption clipping marker

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
Harrison BoothandFredApps authored and GitHub committed 2026-11-10 23:21:48 +01:00
1 parent 3ad707d07b
commit d0b17ef95f
19 files changed
+234 -19

No files matched your search

+1
View File
@@ -313,6 +313,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths. - 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
- **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged. - **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged.
- **2026-10-30 (M6):** Button scripts can read and change the normal push-button caption with `buttonGetCaption` and `buttonSetCaption`. The caption is drawn and retained through undo/redo and save/reopen; unsupported face indices are refused. Estimates unchanged.
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged. - **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. - **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
+1
View File
@@ -645,6 +645,7 @@ impl Automation {
Ok((wi, caption.to_owned())) Ok((wi, caption.to_owned()))
}) })
.transpose()?, .transpose()?,
caption_all: None,
read_only: a.opt_bool("read_only")?, read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?, required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?, multiline: a.opt_bool("multiline")?,
+18
View File
@@ -9501,6 +9501,24 @@ fn javascript_through_tools() {
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" })); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" }));
assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00"))); assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00")));
assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}"); assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}");
ok(
&mut a,
"form_add_field",
json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 150, 120, 180], "name": "CaptionButton", "caption": "Before" }),
);
let r = ok(
&mut a,
"js_run",
json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Continue'); getField('CaptionButton').buttonGetCaption();" }),
);
assert_eq!(r["result"], "Continue");
assert_eq!(
ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().iter().find(|f| f["name"] == "CaptionButton").unwrap()["caption"],
"Continue"
);
let bad_face =
ok(&mut a, "js_run", json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Ignored', 1);" }));
assert!(bad_face["error"].as_str().is_some_and(|e| e.contains("normal button face")));
assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false);
assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err());
} }
+7 -2
View File
@@ -34,11 +34,16 @@ fn button_caption_can_be_changed_after_creation() {
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
let before = a.session().save_bytes(DocId(id)).unwrap(); let before = a.session().save_bytes(DocId(id)).unwrap();
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); let result = tool(
&mut a,
"js_run",
json!({ "doc": id, "field": "clear_button", "script": "getField('clear_button').value='ignored'; getField('clear_button').buttonSetCaption('Clear Form'); getField('clear_button').buttonGetCaption();" }),
);
assert_eq!(result["result"], "Clear Form");
let fields = tool(&mut a, "form_fields", json!({"doc": id})); let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "Clear Form"); assert_eq!(fields["fields"][0]["caption"], "Clear Form");
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}])); assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties")); assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Run JavaScript"));
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance"); assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
tool(&mut a, "edit_undo", json!({"doc": id})); tool(&mut a, "edit_undo", json!({"doc": id}));
+4 -1
View File
@@ -223,6 +223,7 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
_ => f.options.clone(), _ => f.options.clone(),
}; };
s.caption = f.widgets.first().and_then(|w| w.caption.clone());
s s
} }
@@ -252,9 +253,11 @@ fn changes(
read_only: (a.readonly != b.readonly).then_some(a.readonly), read_only: (a.readonly != b.readonly).then_some(a.readonly),
required: (a.required != b.required).then_some(a.required), required: (a.required != b.required).then_some(a.required),
display: (a.display != b.display).then_some(a.display), display: (a.display != b.display).then_some(a.display),
caption: a.caption_was_set.then(|| a.caption.clone()).flatten(),
rect: (a.rect != b.rect).then_some(a.rect).flatten(), rect: (a.rect != b.rect).then_some(a.rect).flatten(),
}; };
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c) (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.caption.is_some() || c.rect.is_some())
.then_some(c)
}) })
.collect() .collect()
} }
+37
View File
@@ -6625,6 +6625,43 @@ fn form_javascript_validates_calculates_and_formats() {
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap();
s.apply(id, Edit::DuplicateField { name: "go".into(), pages: vec![1] }).unwrap();
let props = Box::new(FieldProps { caption: Some((0, "Continue".into())), ..Default::default() });
s.apply(id, Edit::SetFieldProps { name: "go".into(), props }).unwrap();
s.apply(id, Edit::SetFieldProps { name: "go".into(), props: Box::new(FieldProps { caption: Some((1, "Old".into())), ..Default::default() }) })
.unwrap();
let caption =
s.run_javascript(id, "getField('go').buttonSetCaption('Continue'); event.value = getField('go').buttonGetCaption();", Some("go")).unwrap();
assert_eq!(caption.error, None);
assert_eq!(caption.result.as_deref(), Some("Continue"));
assert_eq!(
s.get(id).unwrap().form.iter().find(|f| f.name == "go").map(|f| f.widgets.iter().map(|w| w.caption.as_deref()).collect::<Vec<_>>()),
Some(vec![Some("Continue"), Some("Continue")]),
"setting a caption already shown by the first widget updates every widget",
);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
s.undo(id).unwrap();
assert_eq!(
s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.first()).and_then(|w| w.caption.as_deref()),
Some("Continue"),
"undo keeps the first widget's original caption",
);
assert_eq!(
s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.get(1)).and_then(|w| w.caption.as_deref()),
Some("Old"),
"undo restores the second widget's original caption",
);
s.redo(id).unwrap();
assert_eq!(
s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.first()).and_then(|w| w.caption.as_deref()),
Some("Continue")
);
assert_eq!(
s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.get(1)).and_then(|w| w.caption.as_deref()),
Some("Continue")
);
// JavaScript off: scripts don't run. // JavaScript off: scripts don't run.
s.set_javascript(false); s.set_javascript(false);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
+2
View File
@@ -82,6 +82,8 @@ pub struct FieldProps {
/// Normal push-button label (`/MK /CA`) of one widget (0-based index). /// Normal push-button label (`/MK /CA`) of one widget (0-based index).
/// An empty string clears the label; other widgets and icon layouts are preserved. /// An empty string clears the label; other widgets and icon layouts are preserved.
pub caption: Option<(usize, String)>, pub caption: Option<(usize, String)>,
/// Normal push-button label for every widget in the field.
pub caption_all: Option<String>,
pub read_only: Option<bool>, pub read_only: Option<bool>,
pub required: Option<bool>, pub required: Option<bool>,
pub multiline: Option<bool>, pub multiline: Option<bool>,
+16
View File
@@ -141,6 +141,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
} }
} }
if props.caption_all.is_some() && f.kind != FieldKind::PushButton {
return invalid("only push buttons have a caption");
}
if props.caption_all.is_some() && f.widgets.is_empty() {
return invalid(format!("{name} has no widgets"));
}
if props.caption_all.is_some() && props.caption.is_some() {
return invalid("give a field caption or a widget caption, not both");
}
// Validated before anything is written, so a refused rotation leaves the document unchanged. // Validated before anything is written, so a refused rotation leaves the document unchanged.
if let Some(rotation) = props.rotation { if let Some(rotation) = props.rotation {
check_rotation(&f, name, rotation)?; check_rotation(&f, name, rotation)?;
@@ -577,6 +586,13 @@ fn apply_late_widget_props(doc: &mut Document, f: &Field, props: &FieldProps, ne
mk.set(b"CA".to_vec(), PdfString::text(caption)); mk.set(b"CA".to_vec(), PdfString::text(caption));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
} }
if let Some(caption) = &props.caption_all {
for w in &f.widgets {
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
mk.set(b"CA".to_vec(), PdfString::text(caption));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
}
}
// A label-only edit must not replace another widget's custom appearances. // A label-only edit must not replace another widget's custom appearances.
let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
Ok(only) Ok(only)
+22 -13
View File
@@ -44,6 +44,7 @@ pub struct FieldChange {
pub required: Option<bool>, pub required: Option<bool>,
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
pub display: Option<i32>, pub display: Option<i32>,
pub caption: Option<String>,
/// Move/resize the first widget, in PDF user space. /// Move/resize the first widget, in PDF user space.
pub rect: Option<[f64; 4]>, pub rect: Option<[f64; 4]>,
} }
@@ -127,8 +128,8 @@ pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scrip
} }
/// Apply script changes to other fields (values written without re-running their scripts; /// Apply script changes to other fields (values written without re-running their scripts;
/// read-only, required and visibility as flags). `except` is the event's own field, whose value /// read-only, required and visibility as flags; button captions). `except` is the event's own
/// the event sets. /// field, whose value the event sets.
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> { pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> {
if changes.is_empty() { if changes.is_empty() {
return Ok(()); return Ok(());
@@ -141,18 +142,20 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
&& *v != f.value && *v != f.value
{ {
let value = match f.kind { let value = match f.kind {
FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()), FieldKind::Text => Some(FieldValue::Text(v.first().cloned().unwrap_or_default())),
FieldKind::CheckBox => f.check_box_value(v.first().map(String::as_str)), FieldKind::CheckBox => Some(f.check_box_value(v.first().map(String::as_str))),
FieldKind::Radio => FieldValue::Radio(v.first().cloned()), FieldKind::Radio => Some(FieldValue::Radio(v.first().cloned())),
FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()), FieldKind::Combo | FieldKind::List => Some(FieldValue::Choice(v.clone())),
_ => continue, _ => None,
}; };
let mut tmp = f.clone(); if let Some(value) = value {
tmp.flags &= !flags::NO_TOGGLE_TO_OFF; let mut tmp = f.clone();
tmp.actions = Default::default(); tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
crate::write_value(doc, &tmp, &value, &mut NoScripts)?; tmp.actions = Default::default();
if c.formatted { crate::write_value(doc, &tmp, &value, &mut NoScripts)?;
crate::redraw_calculated_value(doc, f, v, scripts)?; if c.formatted {
crate::redraw_calculated_value(doc, f, v, scripts)?;
}
} }
} }
let mut ff = f.flags; let mut ff = f.flags;
@@ -182,6 +185,12 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
} }
} }
} }
if let Some(caption) = &c.caption {
if f.kind != FieldKind::PushButton {
return Err(FormError::Invalid(format!("{} is not a push button", f.name)));
}
crate::set_props(doc, &f.name, &crate::FieldProps { caption_all: Some(caption.clone()), ..Default::default() })?;
}
if let Some(rect) = c.rect { if let Some(rect) = c.rect {
crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?; crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?;
} }
+39
View File
@@ -221,6 +221,24 @@ fn button_caption_preserves_other_widgets_and_shared_dictionaries() {
assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive"); assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive");
assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj")); assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj"));
} }
let mut doc = original.clone();
set_props(&mut doc, "clear_button", &FieldProps { caption_all: Some("Continue".into()), ..Default::default() }).unwrap();
let doc = Document::open(Arc::new(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let f = field(&fields(&doc), "clear_button").clone();
assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::<Vec<_>>(), [Some("Continue"), Some("Continue")]);
assert!(f.widgets.iter().all(|w| ap(&doc, w).contains("(Continue) Tj")));
let mut doc = original.clone();
apply_script_changes(
&mut doc,
&[FieldChange { name: "clear_button".into(), caption: Some("Proceed".into()), ..Default::default() }],
&mut NoScripts,
)
.unwrap();
let doc = Document::open(Arc::new(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let f = field(&fields(&doc), "clear_button").clone();
assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::<Vec<_>>(), [Some("Proceed"), Some("Proceed")]);
assert!(f.widgets.iter().all(|w| ap(&doc, w).contains("(Proceed) Tj")));
} }
} }
@@ -242,6 +260,26 @@ fn button_caption_rejects_wrong_type_and_index_before_mutation() {
assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj")); assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj"));
} }
#[test]
fn field_wide_button_caption_rejects_a_field_without_widgets() {
let objs = 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 [] >>".into(),
"<< /Fields [5 0 R] >>".into(),
"<< /FT /Btn /T (empty) /Ff 65536 /Kids [] >>".into(),
];
let mut doc = document(&objs);
let all_fields = fields(&doc);
let button = field(&all_fields, "empty");
assert_eq!(button.kind, FieldKind::PushButton);
assert!(button.widgets.is_empty());
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let result = set_props(&mut doc, "empty", &FieldProps { caption_all: Some("Caption".into()), ..Default::default() });
assert!(result.is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
/// Read the drawn strings together with the appearance's actual font after saving and reopening. /// Read the drawn strings together with the appearance's actual font after saving and reopening.
fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec<Vec<u8>>) { fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec<Vec<u8>>) {
let widget = doc.get(w.obj); let widget = doc.get(w.obj);
@@ -3619,6 +3657,7 @@ fn recalc_chain_where_script_changes_checkbox() {
read_only: None, read_only: None,
required: None, required: None,
display: None, display: None,
caption: None,
rect: None, rect: None,
}], }],
message: None, message: None,
+37 -1
View File
@@ -1,4 +1,4 @@
use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, text_line, value_of, write};
use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request};
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
@@ -125,6 +125,38 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
}; };
fn button_get_caption(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
let f = read(c, name)?;
if f.kind != FieldType::Button {
return Err(error(format!("{} is not a push button", f.name)));
}
let face = if args.is_empty() { 0.0 } else { arg(args, 0).to_number(c)? };
if !face.is_finite() || face != 0.0 {
return Err(error("only the normal button face (0) is supported"));
}
Ok(s(f.caption.as_deref().unwrap_or("")))
}
fn button_set_caption(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
let f = read(c, name)?;
if f.kind != FieldType::Button {
return Err(error("buttonSetCaption is only available on push buttons"));
}
let caption_arg = args.first().ok_or_else(|| error("buttonSetCaption requires a caption argument"))?;
let caption = text_line(caption_arg, c)?;
let face = if args.get(1).is_none() { 0.0 } else { arg(args, 1).to_number(c)? };
if !face.is_finite() || face != 0.0 {
return Err(error("only the normal button face (0) is supported"));
}
if host(c)?.borrow().event_name == "Format" {
return Err(error("buttonSetCaption is not available in a Format event"));
}
write(c, name, |f| {
f.caption = Some(caption);
f.caption_was_set = true;
})?;
Ok(JsValue::undefined())
}
fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> { fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
let f = read(c, name)?; let f = read(c, name)?;
let i = arg(args, 0).to_number(c)?; let i = arg(args, 0).to_number(c)?;
@@ -164,6 +196,8 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone());
let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone());
let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone());
let button_get_caption = NativeFunction::from_copy_closure_with_captures(button_get_caption, n.clone());
let button_set_caption = NativeFunction::from_copy_closure_with_captures(button_set_caption, n.clone());
ObjectInitializer::new(ctx) ObjectInitializer::new(ctx)
.accessor(js_string!("value"), Some(value_get), Some(value_set), rw) .accessor(js_string!("value"), Some(value_get), Some(value_set), rw)
.accessor(js_string!("valueAsString"), Some(as_string), None, rw) .accessor(js_string!("valueAsString"), Some(as_string), None, rw)
@@ -185,6 +219,8 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject {
.function(is_box_checked, js_string!("isBoxChecked"), 1) .function(is_box_checked, js_string!("isBoxChecked"), 1)
.function(check_this_box, js_string!("checkThisBox"), 2) .function(check_this_box, js_string!("checkThisBox"), 2)
.function(set_focus, js_string!("setFocus"), 0) .function(set_focus, js_string!("setFocus"), 0)
.function(button_get_caption, js_string!("buttonGetCaption"), 1)
.function(button_set_caption, js_string!("buttonSetCaption"), 2)
.build() .build()
} }
+1
View File
@@ -6,6 +6,7 @@ use super::{
}; };
pub(crate) struct Host { pub(crate) struct Host {
pub(crate) event_name: String,
pub(crate) fields: Arc<Vec<FieldState>>, pub(crate) fields: Arc<Vec<FieldState>>,
pub(crate) changed: BTreeSet<usize>, pub(crate) changed: BTreeSet<usize>,
pub(crate) formatted: BTreeSet<usize>, pub(crate) formatted: BTreeSet<usize>,
+6
View File
@@ -52,6 +52,10 @@ pub struct FieldState {
/// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set.
pub text_color: Option<Vec<String>>, pub text_color: Option<Vec<String>>,
pub fill_color: Option<Vec<String>>, pub fill_color: Option<Vec<String>>,
/// Normal push-button caption, when this is a button.
pub caption: Option<String>,
/// `buttonSetCaption` was called, even when it set the current text again.
pub caption_was_set: bool,
/// Rectangle of the first widget, in PDF user space. /// Rectangle of the first widget, in PDF user space.
pub rect: Option<[f64; 4]>, pub rect: Option<[f64; 4]>,
} }
@@ -70,6 +74,8 @@ impl FieldState {
char_limit: None, char_limit: None,
text_color: None, text_color: None,
fill_color: None, fill_color: None,
caption: None,
caption_was_set: false,
rect: None, rect: None,
} }
} }
+1
View File
@@ -233,6 +233,7 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
ctx.insert_data(RefCell::new(Host { ctx.insert_data(RefCell::new(Host {
event_name: event.name.clone(),
fields, fields,
changed: BTreeSet::new(), changed: BTreeSet::new(),
formatted: BTreeSet::new(), formatted: BTreeSet::new(),
+35
View File
@@ -188,6 +188,40 @@ fn scripts_change_other_fields_and_their_properties() {
assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]); assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]);
} }
#[test]
fn push_button_caption_methods_read_write_and_reject_other_faces() {
let mut button = FieldState::new("go", FieldType::Button, vec![]);
button.caption = Some("Old".into());
let fields = vec![button, FieldState::new("blank", FieldType::Button, vec![]), FieldState::new("text", FieldType::Text, vec![])];
let run = |script: &str| run(script, &Event::field("Mouse Up", "go", ""), &doc(), &fields, &[], Limits::default());
let got = run("event.value = getField('go').buttonGetCaption();");
assert_eq!(got.value, "Old");
let set = run("getField('go').buttonSetCaption('New'); event.value = getField('go').buttonGetCaption();");
assert_eq!(set.value, "New");
assert_eq!(set.changed[0].caption.as_deref(), Some("New"));
let same = run("getField('go').buttonSetCaption('Old');");
assert_eq!(same.changed[0].caption.as_deref(), Some("Old"), "same-text writes still update every widget");
assert!(run("getField('go').buttonSetCaption();").error.is_some());
let long_caption = "x".repeat(crate::xfa::MAX_LINE + 10);
let capped = run(&format!("getField('go').buttonSetCaption('{long_caption}');"));
assert_eq!(capped.error, None);
let caption = capped.changed[0].caption.as_deref().unwrap_or_default();
assert_eq!(caption.chars().count(), crate::xfa::MAX_LINE + 1);
assert!(caption.ends_with('…'));
let explicit = run("event.value = getField('go').buttonGetCaption(0);");
assert_eq!(explicit.value, "Old");
let format =
super::run("getField('go').buttonSetCaption('Discarded');", &Event::field("Format", "go", "Old"), &doc(), &fields, &[], Limits::default());
assert!(format.error.as_deref().is_some_and(|e| e.contains("buttonSetCaption is not available in a Format event")));
assert!(run("getField('go').buttonSetCaption('Bad', 1);").error.is_some());
assert!(run("getField('text').buttonSetCaption('Bad');").error.is_some());
assert!(run("getField('go').buttonGetCaption(1);").error.is_some());
let empty = run("event.value = getField('blank').buttonGetCaption();");
assert_eq!(empty.error, None);
assert_eq!(empty.value, "");
assert!(run("getField('text').buttonGetCaption();").error.is_some());
}
#[test] #[test]
fn widget_rect_reads_and_writes_a_four_number_array() { fn widget_rect_reads_and_writes_a_four_number_array() {
let o = go( let o = go(
@@ -486,6 +520,7 @@ fn app_alert_titles_are_clipped_and_charged() {
// The title's bytes count against the budget with the text's, and a message the budget // The title's bytes count against the budget with the text's, and a message the budget
// has no room for is left out and counted. // has no room for is left out and counted.
let mut h = Host { let mut h = Host {
event_name: "Open".into(),
fields: Arc::new(Vec::new()), fields: Arc::new(Vec::new()),
changed: BTreeSet::new(), changed: BTreeSet::new(),
formatted: BTreeSet::new(), formatted: BTreeSet::new(),
+1
View File
@@ -205,6 +205,7 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action
read_only: None, read_only: None,
required: None, required: None,
display: Some(if hide { 1 } else { 0 }), display: Some(if hide { 1 } else { 0 }),
caption: None,
rect: None, rect: None,
}) })
.collect(); .collect();
@@ -263,6 +263,7 @@ impl FieldDraft {
name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()), name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()),
tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()), tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()),
caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())), caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())),
caption_all: None,
read_only: ch(self.read_only, o.read_only), read_only: ch(self.read_only, o.read_only),
required: ch(self.required, o.required), required: ch(self.required, o.required),
multiline: ch(self.multiline, o.multiline), multiline: ch(self.multiline, o.multiline),
+3
View File
@@ -16,6 +16,7 @@
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
> **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged. > **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged.
@@ -32,6 +33,8 @@
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
> **Last reviewed:** 2026-10-30 | **Last updated:** 2026-10-30 | **Change:** minor (M6: scripts can read and change a push-button's normal caption with undo/redo and save/reopen coverage; estimates unchanged)
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged) > **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and migrate viewer-only CJK configuration to the split engine modules; estimates unchanged) > **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and migrate viewer-only CJK configuration to the split engine modules; estimates unchanged)
+2 -2
View File
@@ -5363,8 +5363,8 @@ milestone = "M6"
status = "partial" status = "partial"
commands = ["tools.js_console"] commands = ["tools.js_console"]
tools = ["js_run"] tools = ["js_run"]
evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/engine/src/tests.rs::js_get_page_box_reads_effective_boxes_without_editing_the_document", "crates/js/src/tests.rs::get_page_box_returns_supported_boxes_in_rotated_user_space"] evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/js/src/tests.rs::push_button_caption_methods_read_write_and_reject_other_faces", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/engine/src/tests.rs::js_get_page_box_reads_effective_boxes_without_editing_the_document", "crates/js/src/tests.rs::get_page_box_returns_supported_boxes_in_rotated_user_space", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation"]
notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), this.getPageBox(cBox, nPage) for effective Media, Crop (default), Trim, Bleed and Art boxes in rotated user space, app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect, and normal push-button captions through buttonGetCaption/buttonSetCaption), this.getPageBox(cBox, nPage) for effective Media, Crop (default), Trim, Bleed and Art boxes in rotated user space, app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)."
[[feature]] [[feature]]
id = "form.js-console" id = "form.js-console"