mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 06:28:49 -06:00
Integrate KenM76 PR #4987
# Conflicts: # crates/forms/src/author/edit.rs # crates/forms/src/tests.rs
This commit is contained in:
@@ -9406,3 +9406,29 @@ fn deleting_one_radio_button_through_tools_keeps_the_group() {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back");
|
||||
}
|
||||
/// `/I` of the top-level field `name` in the saved file `path`.
|
||||
fn saved_selection_indices(path: &Path, name: &str) -> Option<Vec<i64>> {
|
||||
let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
|
||||
let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
|
||||
let f = all
|
||||
.iter()
|
||||
.filter_map(|o| d.resolve(o).as_dict().cloned())
|
||||
.find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name))
|
||||
.unwrap();
|
||||
f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() {
|
||||
let dir = workdir("choice-index");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] }));
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,68 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
|
||||
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
|
||||
}
|
||||
|
||||
/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was
|
||||
/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept`
|
||||
/// are the old `/I` positions carried into the new list, which tell apart options sharing an
|
||||
/// export value; a selected value none of them covers takes its first position, by `set_value`'s
|
||||
/// rule. Returns them.
|
||||
fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec<usize> {
|
||||
let mut indices: Vec<usize> = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect();
|
||||
for v in values {
|
||||
if !indices.iter().any(|&i| exports.get(i) == Some(v)) {
|
||||
indices.extend(exports.iter().position(|e| e == v));
|
||||
}
|
||||
}
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
if indices.is_empty() {
|
||||
d.remove(b"I");
|
||||
} else {
|
||||
d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect()));
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`.
|
||||
fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec<usize> {
|
||||
let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let positions = doc.resolve(&i);
|
||||
positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else
|
||||
/// the field's own, whose selection, `/I` included, moves with them.
|
||||
fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec<usize>) {
|
||||
match &props.options {
|
||||
Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()),
|
||||
None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a choice field's items (export value, display text) by display text, ignoring case, and
|
||||
/// write them as `/Opt`; export values stay with their items. Returns the export values in their
|
||||
/// new order and the `old` positions carried through the sort.
|
||||
fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec<String>, Vec<usize>) {
|
||||
let mut order: Vec<usize> = (0..items.len()).collect();
|
||||
order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase()));
|
||||
let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect();
|
||||
let opt = sorted
|
||||
.iter()
|
||||
.map(|(e, disp)| {
|
||||
if e == disp {
|
||||
Object::String(PdfString::text(disp))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect();
|
||||
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
@@ -124,6 +186,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
return invalid("only check boxes and radio buttons have a check style");
|
||||
}
|
||||
let mut list_picked = None;
|
||||
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
|
||||
if let Some(t) = &props.tooltip {
|
||||
@@ -141,27 +205,23 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
|
||||
let mut new_exports: Option<Vec<String>> = None;
|
||||
let mut kept = Vec::new();
|
||||
if let Some(opts) = &props.options {
|
||||
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
|
||||
new_exports = Some(opts.clone());
|
||||
}
|
||||
// "Sort items": the list itself is kept in order (export values stay with their items).
|
||||
if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
|
||||
let mut items: Vec<(String, String)> = match &props.options {
|
||||
Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(),
|
||||
None => f.options.clone(),
|
||||
};
|
||||
items.sort_by_key(|(_, display)| display.to_lowercase());
|
||||
let opt = items
|
||||
.iter()
|
||||
.map(|(e, disp)| {
|
||||
if e == disp {
|
||||
Object::String(PdfString::text(disp))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection);
|
||||
new_exports = Some(exports);
|
||||
kept = carried;
|
||||
}
|
||||
if let Some(exports) = &new_exports
|
||||
&& matches!(f.kind, FieldKind::Combo | FieldKind::List)
|
||||
{
|
||||
list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len()));
|
||||
}
|
||||
if let Some(q) = props.quadding {
|
||||
d.set(b"Q".to_vec(), Object::Int(q));
|
||||
@@ -249,6 +309,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
})?;
|
||||
}
|
||||
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
|
||||
scroll_list(doc, f.obj, list_picked)?;
|
||||
redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
@@ -396,6 +457,22 @@ fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, joi
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box scrolls, as for a new value, so its first row is still an option and the
|
||||
/// selection is in view. Its rows are measured with the edited font size and rectangle.
|
||||
fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec<usize>, usize)>) -> Result<(), FormError> {
|
||||
if let Some((picked, count)) = list_picked
|
||||
&& let Some(g) = fields(doc).into_iter().find(|g| g.obj == field)
|
||||
{
|
||||
if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count)
|
||||
&& old != new
|
||||
{
|
||||
doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
crate::values::scroll_widgets(doc, &g, &picked, count)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_late_widget_props(
|
||||
doc: &mut Document,
|
||||
f: &Field,
|
||||
|
||||
@@ -5125,4 +5125,146 @@ fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() {
|
||||
set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();
|
||||
assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group");
|
||||
assert_eq!(fields(&doc).len(), 3);
|
||||
/// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent.
|
||||
fn selection_indices(doc: &Document, name: &str) -> Option<Vec<i64>> {
|
||||
let f = field(&fields(doc), name).clone();
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap();
|
||||
d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_repoints_its_selected_indices() {
|
||||
let mut doc = one_page();
|
||||
let list =
|
||||
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None)
|
||||
.unwrap();
|
||||
set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2]));
|
||||
let f = field(&fields(&doc), &list).clone();
|
||||
doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap();
|
||||
// Removing A moves B and C up one place.
|
||||
let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() };
|
||||
set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1]));
|
||||
// Removing B leaves only C selected; the top row would point past the end.
|
||||
set_props(&mut doc, &list, &opts(&["C"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0]));
|
||||
assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row");
|
||||
// No selected value is offered any more.
|
||||
set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
}
|
||||
|
||||
fn top_row(doc: &Document, obj: ObjRef) -> Option<i64> {
|
||||
doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() {
|
||||
let objs: Vec<String> = 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 [7 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let widget = ObjRef::new(7, 0);
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(selection_indices(&doc, "pick"), Some(vec![0]));
|
||||
// The widget's own /TI is read before the field's; row 5 no longer exists.
|
||||
assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0");
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(top_row(&doc, widget), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_widget_with_its_own_top_row_scrolls_by_itself() {
|
||||
// Widget 7 shows from the field's top row; widget 8 keeps its own /TI 4.
|
||||
let objs = |v: &str| -> Vec<String> {
|
||||
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 [7 0 R 8 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(),
|
||||
]
|
||||
};
|
||||
let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0));
|
||||
// The options shrink: the first widget already shows A, the other's row 4 is gone.
|
||||
let mut doc = document(&objs("/V (A) /I [0]"));
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(top_row(&doc, first), None);
|
||||
assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A");
|
||||
// A new value the first widget already shows, below the other's view.
|
||||
let mut doc = document(&objs("/V (B) /I [1]"));
|
||||
set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap();
|
||||
assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move");
|
||||
assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A");
|
||||
}
|
||||
|
||||
/// A list box's top row is chosen with the font size set in the same edit: at the larger size
|
||||
/// fewer rows fit, so it scrolls further to keep the selection in view.
|
||||
#[test]
|
||||
fn a_list_boxs_top_row_follows_a_font_size_changed_with_its_items() {
|
||||
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 [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(),
|
||||
]);
|
||||
let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec();
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let g = field(&fields(&doc), "pick").clone();
|
||||
let w = &g.widgets[0];
|
||||
let rows = w.list.unwrap();
|
||||
let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]);
|
||||
assert!(shown.contains(&3), "C is row 3; rows {shown:?} show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_a_combo_boxs_items_repoints_its_selected_index() {
|
||||
let mut doc = one_page();
|
||||
let combo = add_field(
|
||||
&mut doc,
|
||||
0,
|
||||
[50.0, 600.0, 250.0, 620.0],
|
||||
&NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &combo), Some(vec![0]));
|
||||
set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap();
|
||||
// apple, Fig, Pear: the selection follows Pear to the end.
|
||||
assert_eq!(selection_indices(&doc, &combo), Some(vec![2]));
|
||||
assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
|
||||
let mut doc = one_page();
|
||||
let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap();
|
||||
let f = field(&fields(&doc), &list).clone();
|
||||
let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]);
|
||||
// Zeta and Alpha both export "x"; `/I` says Zeta is the one selected.
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")]));
|
||||
d.set(b"V".to_vec(), PdfString::text("x"));
|
||||
d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)]));
|
||||
})
|
||||
.unwrap();
|
||||
set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap();
|
||||
// Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x".
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
|
||||
}
|
||||
|
||||
+34
-20
@@ -1,8 +1,8 @@
|
||||
use super::appearances::{declares_pdfa, need_appearances};
|
||||
use super::read::{acroform, field_flags, fields, page_refs};
|
||||
use super::{
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author,
|
||||
calc_order, flags, invalid, name_obj, scripting,
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance,
|
||||
author, calc_order, flags, invalid, name_obj, scripting,
|
||||
};
|
||||
|
||||
// ── writing ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -291,14 +291,8 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
indices.sort_unstable_by_key(Object::as_int);
|
||||
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
|
||||
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
|
||||
let top = match (f.kind, f.widgets.first()) {
|
||||
(FieldKind::List, Some(w)) => w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
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());
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] => {
|
||||
@@ -318,16 +312,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
|
||||
}
|
||||
})?;
|
||||
// A widget with its own `/TI` (read before the field's) scrolls too.
|
||||
if let Some((old, new)) = top
|
||||
&& old != new
|
||||
{
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
scroll_widgets(doc, f, &picked, f.options.len())?;
|
||||
redraw(doc, f, &exports, scripts)
|
||||
}
|
||||
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
|
||||
@@ -336,6 +321,35 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of
|
||||
/// its `count` options are among the rows it draws, as in other viewers: a pick below the last
|
||||
/// visible row would otherwise not show (#1524). `None` for any other field.
|
||||
pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count))
|
||||
}
|
||||
|
||||
/// A list box widget's first row before and after scrolling so `picked` shows.
|
||||
fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count))
|
||||
})
|
||||
}
|
||||
|
||||
/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and
|
||||
/// size, so `picked` shows in each.
|
||||
pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> {
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI"))
|
||||
&& let Some((old, new)) = widget_scroll(w, picked, count)
|
||||
&& old != new
|
||||
{
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
|
||||
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
|
||||
let v = on.unwrap_or("Off");
|
||||
|
||||
@@ -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"]
|
||||
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, 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"]
|
||||
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"
|
||||
|
||||
[[feature]]
|
||||
id = "form.props-actions"
|
||||
|
||||
Vendored
+5
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user