mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
Integrate KenM76 PR #4991
# Conflicts: # crates/automation/tests/it/automation.rs # crates/forms/src/author/edit.rs # crates/forms/src/tests.rs # crates/forms/src/values.rs # parity/acrobat-features.toml
This commit is contained in:
@@ -9432,3 +9432,28 @@ fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" }));
|
||||
assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
|
||||
}
|
||||
|
||||
/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
|
||||
/// even when its parent field has one it would otherwise inherit.
|
||||
#[test]
|
||||
fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
|
||||
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] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
|
||||
"<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
let dir = workdir("kid-default");
|
||||
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();
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } }));
|
||||
ok(&mut a, "form_reset", json!({ "doc": doc }));
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
|
||||
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ mod lifecycle;
|
||||
mod look;
|
||||
mod tree;
|
||||
|
||||
pub(crate) use tree::ancestor_sets;
|
||||
|
||||
pub use edit::set_props;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::join::{check_join, join_radio_group};
|
||||
use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
|
||||
use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid};
|
||||
|
||||
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
|
||||
@@ -188,6 +188,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
let mut list_picked = None;
|
||||
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
|
||||
let parent_default = ancestor_sets(doc, f.obj, b"DV");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
|
||||
if let Some(t) = &props.tooltip {
|
||||
@@ -230,6 +231,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
// Buttons name their default state; text and choices hold a string.
|
||||
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))),
|
||||
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
|
||||
// `/DV` is inheritable: with an ancestor's in place, removing the field's own would
|
||||
// bring that one back, so the field keeps an empty default of its own instead.
|
||||
Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => {
|
||||
d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec()))
|
||||
}
|
||||
Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")),
|
||||
Some(None) => {
|
||||
d.remove(b"DV");
|
||||
}
|
||||
|
||||
@@ -317,6 +317,24 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict {
|
||||
add
|
||||
}
|
||||
|
||||
/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without
|
||||
/// its own entry.
|
||||
pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
while let Some(p) = parent {
|
||||
if seen.len() > 64 || !seen.insert(p) {
|
||||
return false;
|
||||
}
|
||||
let Some(d) = doc.get(p).as_dict().cloned() else { return false };
|
||||
if d.contains(key) {
|
||||
return true;
|
||||
}
|
||||
parent = d.reference(b"Parent");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves.
|
||||
pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> {
|
||||
if add.iter().next().is_some() {
|
||||
|
||||
@@ -5268,3 +5268,89 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
|
||||
// Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x".
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
|
||||
let mut doc = fixture();
|
||||
let all = fields(&doc);
|
||||
let city = field(&all, "address.city").clone();
|
||||
let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap();
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]);
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "address.city").clone();
|
||||
assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default);
|
||||
let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own");
|
||||
set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap();
|
||||
reset(&mut doc, None).unwrap();
|
||||
assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it");
|
||||
// Without a parent default, clearing still removes the key.
|
||||
set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let name = field(&fields(&doc), "name").clone();
|
||||
assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_kids_value_does_not_bring_back_its_parents() {
|
||||
// A text kid, then a list box kid, under a parent with both a value and a default.
|
||||
for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] {
|
||||
let mut doc = document(&[
|
||||
"<< /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 [6 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(),
|
||||
]);
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
set_value(&mut doc, "address.city", &set).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}");
|
||||
reset(&mut doc, None).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "address.city").clone();
|
||||
assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value);
|
||||
let mut doc = doc;
|
||||
set_value(&mut doc, "address.city", &set).unwrap();
|
||||
let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) };
|
||||
set_value(&mut doc, "address.city", &clear).unwrap();
|
||||
let f = field(&fields(&reopen(&doc)), "address.city").clone();
|
||||
if ft.contains("Ch") {
|
||||
assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value);
|
||||
} else {
|
||||
assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() {
|
||||
// A check box, then a radio button (flags 49152, inherited from the parent).
|
||||
for flags in ["", "/Ff 49152"] {
|
||||
let mut doc = document(&[
|
||||
"<< /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 [6 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(),
|
||||
"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream".into(),
|
||||
]);
|
||||
let kind = field(&fields(&doc), "opts.a").kind;
|
||||
assert_eq!(kind == FieldKind::Radio, !flags.is_empty());
|
||||
set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let f = field(&fields(&doc), "opts.a").clone();
|
||||
assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}");
|
||||
let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) };
|
||||
set_value(&mut doc, "opts.a", &on).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]);
|
||||
reset(&mut doc, None).unwrap();
|
||||
let f = field(&fields(&doc), "opts.a").clone();
|
||||
assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +210,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
|
||||
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
|
||||
let t = &scripting::accept(doc, f, t, scripts)?;
|
||||
let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
if t.is_empty() {
|
||||
if t.is_empty() && !shadow {
|
||||
d.remove(b"V");
|
||||
} else {
|
||||
d.set(b"V".to_vec(), PdfString::text(t));
|
||||
@@ -293,8 +294,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
let top = list_scroll(f, &picked, f.options.len());
|
||||
// An empty value of its own keeps a parent field's /V from showing through.
|
||||
let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] if shadow => d.set(b"V".to_vec(), PdfString::text("")),
|
||||
[] => {
|
||||
d.remove(b"V");
|
||||
}
|
||||
|
||||
@@ -4739,7 +4739,7 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.clear"]
|
||||
tools = ["form_reset"]
|
||||
evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices"]
|
||||
evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::clearing_a_kids_value_does_not_bring_back_its_parents"]
|
||||
notes = "back to /DV; undoable"
|
||||
|
||||
[[feature]]
|
||||
@@ -5003,8 +5003,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.field.properties"]
|
||||
tools = ["form_set_props"]
|
||||
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items"]
|
||||
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
|
||||
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"]
|
||||
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
|
||||
|
||||
[[feature]]
|
||||
id = "form.props-actions"
|
||||
|
||||
Reference in New Issue
Block a user