M6: Actions tab: replacing a trigger's first action keeps its chain, and the row shows it (#4006)

The actions chained after a trigger's first one (/Next) are kept
whatever replaces it, not only another script, and the row reads
"…, then N more actions". Edit is disabled on every row while a
script is open, so it can't be overwritten.
This commit is contained in:
Marco Arnulfo
2026-10-23 21:02:47 +02:00
parent 61fe4b56a7
commit c5b0e6aa61
7 changed files with 183 additions and 14 deletions
+5
View File
@@ -200,6 +200,11 @@ impl crate::Document {
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default()
}
/// Field Properties ▸ Actions: how many actions are chained after each trigger's first one.
pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> {
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default()
}
/// Document JavaScripts (name, source), in name order.
pub fn document_scripts(&self) -> Vec<(String, String)> {
self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default()
+57 -7
View File
@@ -159,6 +159,60 @@ pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAc
Ok(out)
}
/// Most chained actions [`field_action_chains`] walks per trigger.
const MAX_CHAIN: usize = 1000;
/// How many actions follow `action` through `/Next` (a dictionary or an array of them, each with
/// its own `/Next`), counting each object once.
fn chained(doc: &Document, action: &Object) -> usize {
let mut seen = std::collections::HashSet::new();
if let Object::Ref(r) = action {
seen.insert(*r);
}
let mut stack: Vec<Object> = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect();
let mut count = 0;
while let Some(o) = stack.pop() {
if count >= MAX_CHAIN {
break;
}
if let Object::Ref(r) = &o
&& !seen.insert(*r)
{
continue;
}
match &*doc.resolve(&o) {
Object::Array(items) => stack.extend(items.iter().cloned()),
Object::Dict(d) => {
count += 1;
stack.extend(d.get(b"Next").cloned());
}
_ => {}
}
}
count
}
/// For each trigger of field `name` (its first widget) whose action has others chained after it,
/// how many there are.
pub fn field_action_chains(doc: &Document, name: &str) -> Result<Vec<(Trigger, usize)>, FormError> {
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let Some(w) = f.widgets.first() else { return Ok(Vec::new()) };
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
Ok(Trigger::ALL
.into_iter()
.filter_map(|t| {
let o = match t.key() {
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
Some(k) => aa.get(k).cloned(),
}?;
let n = chained(doc, &o);
(n > 0).then_some((t, n))
})
.collect())
}
fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result<Option<Object>, FormError> {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Action"));
@@ -208,7 +262,7 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) ->
/// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose
/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with
/// their chain and the keys this module doesn't read); a script edited in place keeps its chain.
/// their chain and the keys this module doesn't read); a replaced action keeps its chain.
pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> {
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
@@ -231,12 +285,8 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
if new.is_some() && new == old_action.as_ref() {
continue;
}
let next = match (new, &old_action) {
(Some(FieldAction::JavaScript(_)), Some(FieldAction::JavaScript(_))) => {
old.and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned()))
}
_ => None,
};
// The list shows a trigger's first action: replacing it keeps the ones chained after it.
let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned()));
let obj = match new {
Some(a) => write(doc, a, &pages)?,
None => None,
+1 -1
View File
@@ -24,7 +24,7 @@ mod calc_order;
pub mod detect;
mod scripting;
pub mod table;
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
pub use author::{
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
+33
View File
@@ -1948,6 +1948,39 @@ fn editing_one_script_keeps_the_other_actions_and_the_chain() {
assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into())));
}
#[test]
fn replacing_a_chained_action_with_another_type_keeps_the_chain() {
let mut doc = fixture();
let go = field(&fields(&doc), "go").obj;
let s = |t: &str| Object::String(PdfString::text(t));
let mut named = Dict::new();
named.set(b"S".to_vec(), Object::name("Named"));
named.set(b"N".to_vec(), Object::name("NextPage"));
let named = doc.add(Object::Dict(named));
// A chain as an array: a link, then a menu item that points back at the link (a loop).
let mut uri = Dict::new();
uri.set(b"S".to_vec(), Object::name("URI"));
uri.set(b"URI".to_vec(), s("https://example.org"));
let uri = doc.add(Object::Dict(uri));
doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap();
let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]);
let mut up = Dict::new();
up.set(b"S".to_vec(), Object::name("JavaScript"));
up.set(b"JS".to_vec(), s("app.alert(1);"));
up.set(b"Next".to_vec(), chain.clone());
doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap();
assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]);
set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap();
let w = doc.get(go).as_dict().cloned().unwrap();
let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap();
assert_eq!(a.get(b"Next"), Some(&chain));
assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]);
assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]);
// Deleting the trigger's action deletes its chain with it.
set_field_actions(&mut doc, "go", &[]).unwrap();
assert_eq!(field_action_chains(&doc, "go").unwrap(), []);
}
#[test]
fn push_buttons_read_hide_actions() {
let mut doc = fixture();
+13 -2
View File
@@ -740,6 +740,7 @@ impl crate::PdfCraftApp {
self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default();
d.others = others.clone();
d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default();
d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default();
if let Some(o) = d.original.as_mut() {
o.others = others;
o.actions = d.actions.clone();
@@ -854,6 +855,9 @@ pub struct FieldDraft {
pub new_action: ActionDraft,
/// Actions tab: the trigger whose script `new_action` is editing (Edit), if any.
pub editing: Option<pdfcraft_engine::FieldTrigger>,
/// Actions tab: how many actions the document chains after each trigger's first one. They are
/// kept when that first action is replaced.
pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>,
original: Box<Option<FieldDraft>>,
}
@@ -916,6 +920,7 @@ impl FieldDraft {
actions: Vec::new(),
new_action: ActionDraft::default(),
editing: None,
chained: Vec::new(),
original: Box::new(None),
};
d.original = Box::new(Some(d.clone()));
@@ -1613,14 +1618,20 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens)
for (i, (tr, act)) in d.actions.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!(tr.label())).strong());
ui.label(act.describe());
let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n);
if more == 0 {
ui.label(act.describe());
} else {
let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions");
ui.label(format!("{}, {then}", act.describe()));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.push_id(i, |ui| {
if ui.button(tl!("Delete")).clicked() {
remove = Some(i);
}
if let pdfcraft_engine::FieldAction::JavaScript(js) = act
&& ui.add_enabled(d.editing != Some(*tr), egui::Button::new(tl!("Edit"))).clicked()
&& ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked()
{
edit = Some((*tr, js.clone()));
}
+72 -2
View File
@@ -9,11 +9,15 @@ use pdfcraft_engine::field_table::TableColumn;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| {
harness_with("form.pdf", include_bytes!("../data/form.pdf").to_vec())
}
fn harness_with(name: &'static str, bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| {
cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true);
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
app.open_bytes(name, None, bytes).unwrap();
app.set_option("zoom", "150").unwrap();
app
});
@@ -774,6 +778,72 @@ fn actions_tab_edits_the_script_of_an_existing_action() {
);
}
/// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur
/// runs another script.
fn chained_action_fixture() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>",
"<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>",
"<< /S /URI /URI (https://example.org) >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {
use egui_kittest::kittest::NodeT;
use pdfcraft_engine::FieldTrigger;
let mut h = harness_with("chain.pdf", chained_action_fixture());
h.state_mut().execute("form.prepare");
h.run_steps(2);
let open = |h: &mut Harness<'static, PdfCraftApp>| {
h.state_mut().open_field_props("go", 0);
h.run_steps(2);
h.get_by_label("Actions").click();
h.run_steps(2);
};
open(&mut h);
h.get_by_label("Run a JavaScript, then 1 more action");
// The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur).
assert_eq!(h.get_all_by_label("Edit").count(), 3);
h.get_all_by_label("Edit").nth(1).unwrap().click();
h.run_steps(2);
assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp));
// While one script is open, no other Edit can overwrite it.
assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled()));
h.get_by_label("Discard").click();
h.run_steps(2);
assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled()));
// Replace the Mouse Up script with a link: the chained link stays after it.
{
let d = h.state_mut().field_props.as_mut().unwrap();
d.actions.retain(|(t, _)| *t != FieldTrigger::MouseUp);
d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger: FieldTrigger::MouseUp, kind: 1, text: "https://example.com".into() };
}
h.run_steps(3);
h.get_by_label("Add").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
open(&mut h);
h.get_by_label("Open a web link: https://example.com, then 1 more action");
}
/// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by
/// 120 points from (40, 240) as displayed. The table's window opens for it.
fn draw_table(h: &mut Harness<'static, PdfCraftApp>) {
+2 -2
View File
@@ -4956,8 +4956,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.prepare"]
tools = ["form_actions", "form_set_actions"]
evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain"]
notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions and the chain after an edited script are kept as they are. Edit for the other action types, and several actions per trigger, are missing."
evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain", "crates/forms/src/tests.rs::replacing_a_chained_action_with_another_type_keeps_the_chain", "crates/ui-egui/tests/it/prepare.rs::actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced"]
notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions are kept as they are, and replacing a trigger's first action keeps the actions chained after it, which the row counts. Edit for the other action types, and editing the chained actions, are missing."
[[feature]]
id = "form.props-format"