mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 06:28:49 -06:00
Merge pull request #4631 from Lcoding15/fix/delete-one-radio-button
M6: Delete removes one selected radio button, not its whole group (#4609)
This commit is contained in:
@@ -698,7 +698,14 @@ impl Automation {
|
||||
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
|
||||
return Err(failed(format!("there is no field named {name:?} (see form_fields)")));
|
||||
}
|
||||
self.apply(a, Edit::DeleteField { name })
|
||||
let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) };
|
||||
let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len());
|
||||
let widget = usize::try_from(widget)
|
||||
.ok()
|
||||
.and_then(|w| w.checked_sub(1))
|
||||
.filter(|w| *w < count)
|
||||
.ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?;
|
||||
self.apply(a, Edit::DeleteWidget { name, widget })
|
||||
}
|
||||
|
||||
pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -513,9 +513,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])),
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])),
|
||||
t(
|
||||
"redact_mark",
|
||||
"Mark for redaction",
|
||||
|
||||
@@ -9339,3 +9339,24 @@ fn page_render_draws_stamps_without_an_appearance() {
|
||||
let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count();
|
||||
assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_radio_button_through_tools_keeps_the_group() {
|
||||
let dir = workdir("delete-radio");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (x, export) in [(20, "S"), (40, "M"), (60, "L")] {
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
}
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } }));
|
||||
ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 }));
|
||||
let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone();
|
||||
assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone");
|
||||
for widget in [0, 3, -1] {
|
||||
let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}");
|
||||
}
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back");
|
||||
}
|
||||
|
||||
@@ -292,6 +292,12 @@ pub enum Edit {
|
||||
DeleteField {
|
||||
name: String,
|
||||
},
|
||||
/// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button
|
||||
/// of a group, or one copy of a duplicated field. The last widget takes the field with it.
|
||||
DeleteWidget {
|
||||
name: String,
|
||||
widget: usize,
|
||||
},
|
||||
/// Page properties ▸ Tab order, for pages (0-based).
|
||||
SetTabOrder {
|
||||
pages: Vec<usize>,
|
||||
|
||||
@@ -226,7 +226,7 @@ impl Edit {
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
Edit::AddField { .. } => Label::of("Add field"),
|
||||
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
|
||||
Edit::DeleteField { .. } => Label::of("Delete field"),
|
||||
Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"),
|
||||
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
|
||||
|
||||
@@ -119,6 +119,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::XfaToStatic
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DeleteWidget { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetTabOrder { .. }
|
||||
| Edit::MoveInTabOrder { .. }
|
||||
@@ -479,6 +480,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
}
|
||||
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
|
||||
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
|
||||
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
|
||||
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
|
||||
// As after a script edit: the values the new order gives show at once.
|
||||
|
||||
@@ -16,7 +16,7 @@ mod look;
|
||||
mod tree;
|
||||
|
||||
pub use edit::set_props;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, duplicate_field};
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon};
|
||||
pub(crate) use tree::{ensure_form, form_fonts_as_objects};
|
||||
|
||||
@@ -197,8 +197,78 @@ pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormEr
|
||||
delete_found_field(doc, f)
|
||||
}
|
||||
|
||||
/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the
|
||||
/// field and its other widgets: one radio button of a group, or one copy of a duplicated field,
|
||||
/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted
|
||||
/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was
|
||||
/// on through the deleted widget alone turns off.
|
||||
pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let Some(w) = f.widgets.get(widget).cloned() else {
|
||||
return invalid(format!("{name} has no widget {}", widget.saturating_add(1)));
|
||||
};
|
||||
// A field merged with its widget is that widget's only one.
|
||||
if f.widgets.len() < 2 || w.obj == f.obj {
|
||||
return delete_found_field(doc, f);
|
||||
}
|
||||
let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len();
|
||||
// Turn the group off when no button left *shows* the deleted one's state: a twin with the same
|
||||
// on-state name that is currently off does not keep the choice visible.
|
||||
let on_only_here = w.on_state.as_deref().is_some_and(|on| {
|
||||
f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on))
|
||||
});
|
||||
drop_from_pages(doc, &std::iter::once(w.obj).collect())?;
|
||||
detach(doc, w.obj)?;
|
||||
drop_from_calculation_order(doc, |r| r == w.obj)?;
|
||||
let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut changed = false;
|
||||
// The field's own `/Kids` is what made the widget its: `detach` follows the widget's
|
||||
// `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep
|
||||
// a widget no page shows.
|
||||
if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())
|
||||
&& kids.iter().any(|k| k.as_ref() == Some(w.obj))
|
||||
{
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect()));
|
||||
changed = true;
|
||||
}
|
||||
if per_widget_opt
|
||||
&& let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())
|
||||
&& opt.len() == f.widgets.len()
|
||||
{
|
||||
let kept: Vec<Object> = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(kept));
|
||||
changed = true;
|
||||
}
|
||||
if on_only_here && d.get(b"V").is_some() {
|
||||
d.set(b"V".to_vec(), Object::name("Off"));
|
||||
changed = true;
|
||||
}
|
||||
// The default (`/DV`, what Reset puts back) can't name a state no button has any more.
|
||||
let dv_only_here = w.on_state.as_deref().is_some_and(|on| {
|
||||
d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes())
|
||||
&& !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on))
|
||||
});
|
||||
if dv_only_here {
|
||||
d.set(b"DV".to_vec(), Object::name("Off"));
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
doc.set(f.obj, Object::Dict(d));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
|
||||
let widgets: std::collections::HashSet<ObjRef> = f.widgets.iter().map(|w| w.obj).collect();
|
||||
drop_from_pages(doc, &widgets)?;
|
||||
detach(doc, f.obj)?;
|
||||
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
|
||||
// widgets reachable for a full save.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
|
||||
}
|
||||
|
||||
/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference.
|
||||
fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet<ObjRef>) -> Result<(), FormError> {
|
||||
for p in page_refs(doc) {
|
||||
let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
|
||||
let list = doc.resolve(&a).as_array().cloned().unwrap_or_default();
|
||||
@@ -210,10 +280,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
detach(doc, f.obj)?;
|
||||
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
|
||||
// widgets reachable for a full save.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod table;
|
||||
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,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
|
||||
@@ -4687,3 +4687,150 @@ fn set_values_reads_the_form_once_not_per_value() {
|
||||
let reads = form_reads() - before;
|
||||
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
|
||||
}
|
||||
|
||||
/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and
|
||||
/// the group's buttons in the calculation order; and a text field `name` with two widgets.
|
||||
fn radio_group_form() -> Document {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R] >>".into(), // 3
|
||||
"<< /Fields [5 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /CO [7 0 R] >>".into(), // 4
|
||||
"<< /FT /Btn /Ff 49152 /T (size) /V /S /Opt [(small) (medium) (large)] /Kids [6 0 R 7 0 R 8 0 R] >>".into(), // 5
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 64 714] /P 3 0 R /AS /S /AP << /N << /S 12 0 R /Off 12 0 R >> >> >>".into(), // 6
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [80 700 94 714] /P 3 0 R /AS /Off /AP << /N << /M 12 0 R /Off 12 0 R >> >> >>".into(), // 7
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [110 700 124 714] /P 3 0 R /AS /Off /AP << /N << /L 12 0 R /Off 12 0 R >> >> >>".into(), // 8
|
||||
"<< /FT /Tx /T (name) /V (Ann) /Kids [10 0 R 11 0 R] >>".into(), // 9
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [300 600 500 620] /P 3 0 R >>".into(), // 11
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 12
|
||||
];
|
||||
document(&objs)
|
||||
}
|
||||
|
||||
fn annots(doc: &Document) -> Vec<u32> {
|
||||
let p = page_refs(doc)[0];
|
||||
let a = doc.resolve(doc.get(p).as_dict().unwrap().get(b"Annots").unwrap());
|
||||
a.as_array().unwrap().iter().map(|o| o.as_ref().unwrap().num).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_radio_button_keeps_the_group_and_its_other_buttons() {
|
||||
let mut doc = radio_group_form();
|
||||
delete_widget(&mut doc, "size", 1).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let size = field(&all, "size");
|
||||
assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [6, 8], "only Medium is gone");
|
||||
assert_eq!(size.value, ["S"], "the group keeps its choice");
|
||||
// The per-widget /Opt loses Medium's entry, so the others keep their export values.
|
||||
assert_eq!((size.export_of(0), size.export_of(1)), (Some("small"), Some("large")));
|
||||
assert_eq!(annots(&doc), [6, 8, 10, 11], "the button left the page");
|
||||
let co = doc.resolve(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().get(b"CO").unwrap()).as_array().cloned().unwrap();
|
||||
assert!(co.is_empty(), "and the calculation order");
|
||||
assert!(field(&all, "name").widgets.len() == 2, "other fields are untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_chosen_radio_button_turns_the_group_off() {
|
||||
let mut doc = radio_group_form();
|
||||
delete_widget(&mut doc, "size", 0).unwrap();
|
||||
let all = fields(&reopen(&doc));
|
||||
let size = field(&all, "size");
|
||||
assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [7, 8]);
|
||||
assert!(size.value.is_empty(), "no button is on any more: {:?}", size.value);
|
||||
assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown_on() {
|
||||
// Medium shares Small's on-state name but shows Off: once Small is gone, no button shows
|
||||
// the group's /V, so the group must not keep reporting it.
|
||||
let mut doc = radio_group_form();
|
||||
let twin = ObjRef::new(7, 0);
|
||||
let blank = Object::Ref(ObjRef::new(12, 0));
|
||||
let mut n = Dict::new();
|
||||
n.set(b"S".to_vec(), blank.clone());
|
||||
n.set(b"Off".to_vec(), blank);
|
||||
let mut ap = Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(n));
|
||||
let mut w = doc.get(twin).as_dict().cloned().unwrap();
|
||||
w.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
doc.set(twin, Object::Dict(w));
|
||||
delete_widget(&mut doc, "size", 0).unwrap();
|
||||
let all = fields(&reopen(&doc));
|
||||
let size = field(&all, "size");
|
||||
assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [7, 8]);
|
||||
assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_default_radio_button_clears_the_default_too() {
|
||||
// The group is off, but Reset would put back Small, which no button has once it is gone.
|
||||
let mut doc = radio_group_form();
|
||||
let size = ObjRef::new(5, 0);
|
||||
let mut d = doc.get(size).as_dict().cloned().unwrap();
|
||||
d.set(b"V".to_vec(), Object::name("Off"));
|
||||
d.set(b"DV".to_vec(), Object::name("S"));
|
||||
doc.set(size, Object::Dict(d));
|
||||
delete_widget(&mut doc, "size", 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let dv = doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned());
|
||||
assert_eq!(dv, Some(Object::name("Off")));
|
||||
// A default another button still has stays.
|
||||
let mut doc = radio_group_form();
|
||||
let mut d = doc.get(size).as_dict().cloned().unwrap();
|
||||
d.set(b"DV".to_vec(), Object::name("S"));
|
||||
doc.set(size, Object::Dict(d));
|
||||
delete_widget(&mut doc, "size", 1).unwrap();
|
||||
assert_eq!(doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()), Some(Object::name("S")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() {
|
||||
let mut doc = radio_group_form();
|
||||
delete_widget(&mut doc, "name", 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let name = field(&all, "name");
|
||||
assert_eq!((name.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), name.value.clone()), (vec![11], vec!["Ann".to_string()]));
|
||||
assert_eq!(annots(&doc), [6, 7, 8, 11]);
|
||||
// The last widget takes the field with it, as deleting the field does.
|
||||
let mut doc = doc;
|
||||
delete_widget(&mut doc, "name", 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert!(!fields(&doc).iter().any(|f| f.name == "name"));
|
||||
assert_eq!(annots(&doc), [6, 7, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_radio_button_without_its_parent_link_takes_it_out_of_the_group() {
|
||||
// A damaged file: the group lists Medium in its /Kids, but Medium has no /Parent.
|
||||
for parent in [None, Some(Object::Ref(ObjRef::new(9, 0)))] {
|
||||
let mut doc = radio_group_form();
|
||||
let medium = ObjRef::new(7, 0);
|
||||
let mut w = doc.get(medium).as_dict().cloned().unwrap();
|
||||
w.remove(b"Parent");
|
||||
if let Some(p) = parent.clone() {
|
||||
w.set(b"Parent".to_vec(), p);
|
||||
}
|
||||
doc.set(medium, Object::Dict(w));
|
||||
delete_widget(&mut doc, "size", 1).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "size").widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [6, 8], "/Parent {parent:?}");
|
||||
let kids = doc.resolve(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().get(b"Kids").unwrap()).as_array().cloned().unwrap();
|
||||
assert!(!kids.iter().any(|k| k.as_ref() == Some(medium)), "/Parent {parent:?}: {kids:?}");
|
||||
assert_eq!(field(&all, "name").widgets.len(), 2, "/Parent {parent:?}: the other field is untouched");
|
||||
assert_eq!(annots(&doc), [6, 8, 10, 11]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_widget_that_is_not_there_changes_nothing() {
|
||||
let mut doc = radio_group_form();
|
||||
assert_eq!(delete_widget(&mut doc, "size", 3), Err(FormError::Invalid("size has no widget 4".into())));
|
||||
assert_eq!(delete_widget(&mut doc, "nope", 0), Err(FormError::NoSuchField("nope".into())));
|
||||
assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX))));
|
||||
assert_eq!(field(&fields(&doc), "size").widgets.len(), 3);
|
||||
}
|
||||
|
||||
@@ -668,6 +668,7 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
| Edit::ResetForm { .. }
|
||||
| Edit::AddField { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DeleteWidget { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::SetFieldImage { .. }
|
||||
|
||||
@@ -593,20 +593,24 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc
|
||||
}
|
||||
|
||||
/// Delete the selected field and every field selected with it (#95), as one undoable step, and
|
||||
/// clear the selection.
|
||||
/// clear the selection. Each selection is one widget, so selecting one radio button of a group,
|
||||
/// or one copy of a duplicated field, deletes only that one, as in Acrobat (#4609); a field's
|
||||
/// last widget takes the field with it.
|
||||
pub(crate) fn delete_selected(view: &mut DocView) -> Option<Edit> {
|
||||
let (first, _) = view.prepare.selected.take()?;
|
||||
let mut names = vec![first];
|
||||
for (name, _) in std::mem::take(&mut view.prepare.also) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
let first = view.prepare.selected.take()?;
|
||||
let mut picked = vec![first];
|
||||
for w in std::mem::take(&mut view.prepare.also) {
|
||||
if !picked.contains(&w) {
|
||||
picked.push(w);
|
||||
}
|
||||
}
|
||||
if names.len() == 1 {
|
||||
return names.pop().map(|name| Edit::DeleteField { name });
|
||||
// A field's later widgets first, so the indices of the ones still to go don't shift.
|
||||
picked.sort_by_key(|w| std::cmp::Reverse(w.1));
|
||||
if picked.len() == 1 {
|
||||
return picked.pop().map(|(name, widget)| Edit::DeleteWidget { name, widget });
|
||||
}
|
||||
let label = format!("Delete {} fields", names.len());
|
||||
Some(Edit::Batch { label, edits: names.into_iter().map(|name| Edit::DeleteField { name }).collect() })
|
||||
let label = format!("Delete {} fields", picked.len());
|
||||
Some(Edit::Batch { label, edits: picked.into_iter().map(|(name, widget)| Edit::DeleteWidget { name, widget }).collect() })
|
||||
}
|
||||
|
||||
/// The arrow keys move the selected field a point, Shift and an arrow ten points, as in Acrobat,
|
||||
|
||||
@@ -1216,3 +1216,77 @@ fn a_loop_has_its_own_card_that_opens_the_formula() {
|
||||
let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab));
|
||||
assert_eq!(draft, Some(("a".to_owned(), FieldTab::Calculate)));
|
||||
}
|
||||
|
||||
/// A radio group `shirt` of three buttons, S, M and L, with L chosen, while preparing the form.
|
||||
fn radio_group_harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = harness();
|
||||
for (x, export) in [(20.0, "S"), (60.0, "M"), (100.0, "L")] {
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||||
page: 0,
|
||||
rect: [x, 300.0, x + 14.0, 314.0],
|
||||
kind: pdfcraft_engine::NewField::Radio { group: Some("shirt".into()), export: export.into() },
|
||||
name: Some("shirt".into()),
|
||||
});
|
||||
}
|
||||
h.state_mut()
|
||||
.apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "shirt".into(), value: pdfcraft_engine::FieldValue::Radio(Some("L".into())) });
|
||||
assert!(h.state_mut().execute("form.prepare"));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn buttons(h: &Harness<'static, PdfCraftApp>) -> (Vec<String>, Vec<String>) {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == "shirt").expect("the group is still there");
|
||||
(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect(), f.value.clone())
|
||||
}
|
||||
|
||||
/// #4609: Delete removes the one radio button clicked, not its whole group, as in Acrobat.
|
||||
#[test]
|
||||
fn deleting_one_radio_button_keeps_the_rest_of_its_group() {
|
||||
let mut h = radio_group_harness();
|
||||
assert_eq!(buttons(&h).0, ["S", "M", "L"]);
|
||||
let p = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == "shirt").unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 1).expect("on screen").center()
|
||||
};
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].prepare.selected, Some(("shirt".into(), 1)), "the middle button is selected");
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert_eq!(buttons(&h), (vec!["S".into(), "L".into()], vec!["L".into()]), "only M is gone; L stays chosen");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete field"));
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
assert_eq!(buttons(&h).0, ["S", "M", "L"]);
|
||||
}
|
||||
|
||||
/// Two buttons of one group selected together both go, whichever was selected first.
|
||||
#[test]
|
||||
fn deleting_two_radio_buttons_of_a_group_at_once() {
|
||||
let mut h = radio_group_harness();
|
||||
{
|
||||
let p = &mut h.state_mut().views[0].prepare;
|
||||
p.selected = Some(("shirt".into(), 0));
|
||||
p.also = vec![("shirt".into(), 2)];
|
||||
}
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert_eq!(buttons(&h), (vec!["M".into()], vec![]), "M is left, and nothing is chosen");
|
||||
// Selecting every button deletes the group.
|
||||
{
|
||||
let p = &mut h.state_mut().views[0].prepare;
|
||||
p.selected = Some(("shirt".into(), 0));
|
||||
}
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert!(!names(&h).iter().any(|n| n == "shirt"));
|
||||
}
|
||||
|
||||
@@ -4823,8 +4823,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.prepare"]
|
||||
tools = ["form_add_field", "form_set_props", "form_delete_field"]
|
||||
evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/tools/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"]
|
||||
notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits"
|
||||
evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::deleting_one_radio_button_keeps_the_rest_of_its_group", "crates/ui-egui/tests/it/prepare.rs::deleting_two_radio_buttons_of_a_group_at_once", "crates/forms/src/tests.rs::deleting_one_radio_button_keeps_the_group_and_its_other_buttons", "crates/forms/src/tests.rs::deleting_the_chosen_radio_button_turns_the_group_off", "crates/forms/src/tests.rs::deleting_one_copy_of_a_field_keeps_the_field_and_its_value", "crates/automation/tests/it/automation.rs::deleting_one_radio_button_through_tools_keeps_the_group", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/tools/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"]
|
||||
notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step; a selected radio button or copy of a duplicated field goes on its own and the rest of its group stays, a field's last widget takes the field, #4609); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits"
|
||||
|
||||
[[feature]]
|
||||
id = "form.add-text-field"
|
||||
|
||||
Reference in New Issue
Block a user