From 81f38ae6182035f5f2e53dda9059777bb299325f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:37:01 +0800 Subject: [PATCH] M6: list boxes show and pick the option /I names, and a single-select list shows one value Options are tracked by position as well as export value, so two options sharing an export value (ISO 32000-2 Table 234, /I) show and pick the right row; a single-select list given several values holds and shows the first; /I matches /V item for item, repeats included; values match options in linear time. Rebased onto main as one commit: main split the forms crate's lib.rs into read.rs and values.rs and moved forms_ui.rs into tools/. Folds the branch's four commits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/src/forms.rs | 3 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/list_selection.rs | 81 +++++++ crates/automation/tests/it/main.rs | 1 + crates/forms/src/appearance.rs | 8 +- crates/forms/src/lib.rs | 61 ++++- crates/forms/src/read.rs | 87 ++++++- crates/forms/src/tests.rs | 224 +++++++++++++++++++ crates/forms/src/values.rs | 84 ++++--- crates/ui-egui/src/tools/forms_ui.rs | 48 ++-- crates/ui-egui/tests/it/forms.rs | 92 ++++++++ parity/acrobat-features.toml | 6 +- 12 files changed, 632 insertions(+), 65 deletions(-) create mode 100644 crates/automation/tests/it/list_selection.rs diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index adf0bd0884..fba7bb1b51 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -297,6 +297,9 @@ impl Automation { } FormFieldKind::Combo | FormFieldKind::List => { o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); + // The selected options as 1-based positions in `options`: two options can share + // an export value, and `value` alone does not tell them apart. + o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::>())); o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT))); o.insert("editable".into(), json!(f.has(field_flags::EDIT))); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..96f49814ce 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -391,7 +391,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Combo and list boxes include selected: the 1-based positions in options of the selected ones, which tells apart options that share an export value.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( diff --git a/crates/automation/tests/it/list_selection.rs b/crates/automation/tests/it/list_selection.rs new file mode 100644 index 0000000000..2c2dc041ef --- /dev/null +++ b/crates/automation/tests/it/list_selection.rs @@ -0,0 +1,81 @@ +//! Which options a list box has selected, through the tool table (ISO 32000-2 Table 234, `/I`). + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + 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{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(test: &str) -> (Automation, u64, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("pdfcraft-list-selection-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let bytes = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (D_dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (H_twovals) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 150 200 220] /P 3 0 R /F 4 >>", + ]); + std::fs::write(dir.join("choice.pdf"), bytes).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({ "path": "choice.pdf" }))["doc"].as_u64().unwrap(); + (a, id, dir) +} + +fn field(a: &mut Automation, doc: u64, name: &str) -> Value { + let all = tool(a, "form_fields", json!({ "doc": doc })); + all["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap().clone() +} + +/// Two options share the export value `1`: `selected` says which one `/I` picks, and filling the +/// field with an option's label picks that option. +#[test] +fn form_fields_reports_which_of_two_options_with_one_export_value_is_selected() { + let (mut a, doc, dir) = open("dup"); + let d = field(&mut a, doc, "D_dup"); + assert_eq!(d["value"], "1"); + assert_eq!(d["selected"], json!([2]), "One-B, the second option: {d}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-A" } })); + let d = field(&mut a, doc, "D_dup"); + assert_eq!((d["value"].clone(), d["selected"].clone()), (json!("1"), json!([1])), "{d}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-B" } })); + let d = field(&mut a, doc, "D_dup"); + assert_eq!(d["selected"], json!([2])); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A list box without MultiSelect given `/V [(TW) (JP)]` reports Taiwan alone (ISO 32000-2 +/// Table 233: "at most one item shall be selected"), and filling it writes one value. +#[test] +fn form_fields_reports_one_value_for_a_single_select_list_given_two() { + let (mut a, doc, dir) = open("twovals"); + let h = field(&mut a, doc, "H_twovals"); + assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("TW"), json!([1])), "{h}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "H_twovals": "United States" } })); + let h = field(&mut a, doc, "H_twovals"); + assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("US"), json!([3])), "{h}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d36dbe93a9..3e0dcc822f 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,6 +8,7 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; +mod list_selection; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c9ff86d17a..4780001ebb 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -814,11 +814,15 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] size = rows.size; let line = rows.row; let mut y = height - rows.inset; - for (export, display) in f.options.iter().skip(rows.top) { + // The field's own selection (its `/I`) when these are its values; values it doesn't + // hold yet select by export value alone. + let picked = if values == f.value.as_slice() { f.selected.clone() } else { crate::selected_options(&f.options, values, None) }; + for (i, (_, display)) in f.options.iter().enumerate().skip(rows.top) { if y - line < 0.0 { break; } - if values.contains(export) { + // Both sources list positions in ascending order. + if picked.binary_search(&i).is_ok() { c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line))); } show(&mut body, pad, y - line + size * 0.25, display); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 775306d865..d6cf3a2012 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -42,10 +42,11 @@ mod values; pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; pub(crate) use read::page_refs; +pub use read::selected_options; pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; #[cfg(test)] use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; -use read::{script, script_budget}; +use read::{first_by_export, script, script_budget}; pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; @@ -159,6 +160,10 @@ pub struct Field { pub max_len: Option, /// Choice options: (export value, display text). pub options: Vec<(String, String)>, + /// Combo and list boxes: the selected options, as ascending positions in `options` (see + /// [`selected_options`]). Two options can share an export value, so `value` alone can't say + /// which of them is selected. Empty for other kinds. + pub selected: Vec, /// Default appearance string (`/DA`, inherited from the form if absent). pub da: String, /// Quadding: 0 left, 1 centred, 2 right. @@ -229,18 +234,56 @@ impl Field { } } + /// Combo and list boxes: the selected options in the order the value names them (`/V` keeps + /// the order they were picked in), so the last is the latest pick. + pub fn picked_in_order(&self) -> Vec { + let mut out: Vec = Vec::with_capacity(self.selected.len()); + let mut taken: std::collections::HashSet = std::collections::HashSet::with_capacity(self.selected.len()); + let mut unpicked = self.selected_by_export(); + for v in &self.value { + if let Some(i) = unpicked.get_mut(v.as_str()).and_then(std::collections::VecDeque::pop_front) { + taken.insert(i); + out.push(i); + } + } + // Positions `/I` holds beyond what the value names (none while the two agree). + out.extend(self.selected.iter().copied().filter(|i| !taken.contains(i))); + out + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { - FieldKind::Combo | FieldKind::List => self - .value - .iter() - .map(|v| self.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone())) - .collect::>() - .join(", "), + FieldKind::Combo | FieldKind::List => { + // Each value as the selected option it names (options can share an export + // value), in the value's order; past those, the first option exporting it. + let mut unpicked = self.selected_by_export(); + let first = first_by_export(&self.options); + self.value + .iter() + .map(|v| { + let i = + unpicked.get_mut(v.as_str()).and_then(std::collections::VecDeque::pop_front).or_else(|| first.get(v.as_str()).copied()); + i.and_then(|i| self.options.get(i)).map_or(v.clone(), |(_, d)| d.clone()) + }) + .collect::>() + .join(", ") + } _ => self.value.join(", "), } } + + /// The selected positions grouped by the export value of their option, each group + /// ascending, so a value takes the next selected option exporting it in one lookup. + fn selected_by_export(&self) -> std::collections::HashMap<&str, std::collections::VecDeque> { + let mut out: std::collections::HashMap<&str, std::collections::VecDeque> = std::collections::HashMap::new(); + for &i in &self.selected { + if let Some((e, _)) = self.options.get(i) { + out.entry(e.as_str()).or_default().push_back(i); + } + } + out + } } /// A value to put in a field. @@ -253,6 +296,10 @@ pub enum FieldValue { Radio(Option), /// Combo box or list box: export values (several only for multi-select lists). Choice(Vec), + /// Combo box or list box: options by their zero-based position in `/Opt` (as `/I` numbers + /// them), which tells apart options that share an export value. Several only for + /// multi-select lists; `/V` takes them in this order. + ChoiceIndices(Vec), } // ── reading ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index e689c0e813..177e32fd1d 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -201,6 +201,69 @@ fn values_of(doc: &Document, o: Option<&Object>) -> Vec { } } +/// A choice field's `/I` (ISO 32000-2 Table 234) as option positions, when it is usable: an array +/// of integers "sorted in ascending order", each naming one of `count` options. `None` when it is +/// absent or anything in it is not (a non-integer, a position out of range, out of order or +/// repeated), so the field falls back to `/V`. +pub(crate) fn listed_indices(doc: &Document, d: &Dict, count: usize) -> Option> { + let listed = doc.resolve(d.get(b"I")?); + let entries = listed.as_array()?; + // Strictly ascending positions below `count` number at most `count`. + if entries.len() > count { + return None; + } + let mut out: Vec = Vec::with_capacity(entries.len()); + for e in entries { + let i = usize::try_from(doc.resolve(e).as_int()?).ok().filter(|&i| i < count)?; + if out.last().is_some_and(|&last| last >= i) { + return None; + } + out.push(i); + } + Some(out) +} + +/// The options a choice field shows selected, as ascending positions in `options`, given its +/// values (`/V`) and its usable `/I` ([`listed_indices`]). +/// +/// `/I` decides when it names the same export values as `/V`: it "shall be used when two or more +/// elements in the Opt array have different names but the same export value", and "if the items +/// identified by this entry differ from those in the V entry …, the V entry shall be used" (ISO +/// 32000-2 Table 234). Otherwise each value selects the first option with that export value, as +/// the writer numbers `/I` when it is given only an export value, so a pick keeps showing the same +/// option when `/I` is lost. A value no option exports selects nothing. +pub fn selected_options(options: &[(String, String)], values: &[String], listed: Option<&[usize]>) -> Vec { + if let Some(listed) = listed { + // The same items, each as many times: `/V [(1) (1)]` names two items, which an `/I [1]` + // naming one doesn't, so `/V` decides then. + let mut named: Vec<&str> = values.iter().map(String::as_str).collect(); + named.sort_unstable(); + let exports: Option> = listed.iter().map(|&i| options.get(i).map(|(e, _)| e.as_str())).collect(); + if let Some(mut exports) = exports { + exports.sort_unstable(); + if exports == named { + return listed.to_vec(); + } + } + } + let first = first_by_export(options); + let mut out: Vec = values.iter().filter_map(|v| first.get(v.as_str()).copied()).collect(); + out.sort_unstable(); + out.dedup(); + out +} + +/// Each export value in `options` with the position of the first option exporting it, so +/// matching values against options takes one pass over each rather than one over the options per +/// value (a file can hold many of both). +pub(crate) fn first_by_export(options: &[(String, String)]) -> std::collections::HashMap<&str, usize> { + let mut first: std::collections::HashMap<&str, usize> = std::collections::HashMap::with_capacity(options.len()); + for (i, (e, _)) in options.iter().enumerate() { + first.entry(e.as_str()).or_insert(i); + } + first +} + fn nums(doc: &Document, o: Option<&Object>) -> Option> { let o = doc.resolve(o?); o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() @@ -544,16 +607,34 @@ fn walk( Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), other => text_of(other).map(|t| (t.clone(), t)), }) - .collect(); + .collect::>(); + let mut value = values_of(doc, inh.v.as_ref()); + // A list box without MultiSelect selects one option: "if clear, at most one item shall be + // selected" (ISO 32000-2 Table 233). Given an array /V or /DV anyway, it holds the first + // value only, and shows and resets to it; the file keeps its entries until the field is filled. + let mut default = values_of(doc, inh.dv.as_ref()); + let single = kind == FieldKind::List && ff & flags::MULTI_SELECT == 0; + if single { + value.truncate(1); + default.truncate(1); + } + let mut selected = match kind { + FieldKind::Combo | FieldKind::List => selected_options(&options, &value, listed_indices(doc, d, options.len()).as_deref()), + _ => Vec::new(), + }; + if single { + selected.truncate(1); + } out.push(Field { name: inh.name.clone(), obj: r, kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), + value, + default, flags: ff, max_len: inh.max_len, options, + selected, da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), quadding: inh.q.unwrap_or(0), tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..c76f934553 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5485,3 +5485,227 @@ fn locked_button_export_edits_leave_the_document_unchanged() { assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } } + +/// A one-page form with list box `dup` (single select unless `ff` says otherwise) whose first two +/// options share the export value `1`, plus `extra` entries (`/V`, `/I`) on the field. +fn duplicate_exports(ff: u32, extra: &str) -> Document { + let objs: Vec = 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 [6 0 R] >>".into(), + "<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Ff {ff} /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] {extra} /Rect [50 700 200 746] /P 3 0 R >>" + ), + ]; + document(&objs) +} + +/// The bottom edges of the highlighted rows in a list box appearance, top row first. +fn highlighted_rows(appearance: &str) -> Vec { + let lines: Vec<&str> = appearance.lines().collect(); + lines.windows(2).filter(|w| w[0] == "0.6 0.75 0.86 rg").map(|w| w[1].split(' ').nth(1).unwrap_or_default().to_string()).collect() +} + +/// The rows the display copy (`/NeedAppearances`) highlights in `dup`. +fn shown_rows(doc: &Document) -> Vec { + let copy = with_field_appearances(doc).expect("the form leaves its appearances to the viewer"); + highlighted_rows(&ap(©, &field(&fields(©), "dup").widgets[0])) +} + +/// ISO 32000-2 Table 234: `/I` "shall be used when two or more elements in the Opt array have +/// different names but the same export value". `/V (1)` with `/I [1]` selects One-B alone, not +/// every option exporting `1`. +#[test] +fn a_list_box_with_duplicate_export_values_selects_the_option_i_names() { + let doc = duplicate_exports(0, "/V (1) /I [1]"); + let dup = field(&fields(&doc), "dup").clone(); + assert_eq!(dup.selected, [1]); + assert_eq!(dup.display_value(), "One-B"); + let one_b = shown_rows(&doc); + assert_eq!(one_b.len(), 1, "one highlighted row"); + // The same field with /I [0] highlights the row above it. + let one_a = shown_rows(&duplicate_exports(0, "/V (1) /I [0]")); + assert_eq!(one_a.len(), 1); + assert_ne!(one_a, one_b, "One-A and One-B are different rows"); + let row = |s: &[String]| s.first().and_then(|y| y.parse::().ok()).unwrap(); + assert!(row(&one_b) < row(&one_a), "One-B is drawn below One-A"); +} + +/// Without a usable `/I`, `/V` decides (Table 234: "If the items identified by this entry differ +/// from those in the V entry …, the V entry shall be used"), and an export value two options +/// share selects the first of them. A malformed `/I` never panics. +#[test] +fn an_unusable_i_falls_back_to_the_first_option_with_the_value() { + for (extra, multi, want) in [ + ("/V (1)", 0, vec![0]), // no /I: the first option exporting 1 + ("/V (1) /I [5]", 0, vec![0]), // out of range + ("/V (1) /I [-1]", 0, vec![0]), // negative + ("/V (1) /I [(1)]", 0, vec![0]), // not an integer + ("/V (1) /I [1.5]", 0, vec![0]), // not a whole number + ("/V (1) /I 1", 0, vec![0]), // not an array + ("/V (1) /I [2]", 0, vec![0]), // names Two, which /V doesn't + ("/V (1) /I []", 0, vec![0]), // names nothing + ("/V [(1) (2)] /I [2 1]", 1 << 21, vec![0, 2]), // not ascending + ("/V [(1) (2)] /I [1 1 2]", 1 << 21, vec![0, 2]), // repeated + ("/V [(1) (2)] /I [1 2]", 1 << 21, vec![1, 2]), // usable: One-B and Two + ("/V [(1) (1)] /I [0 1]", 1 << 21, vec![0, 1]), // usable: both options exporting 1 + ("/V [(1) (1)] /I [1]", 1 << 21, vec![0]), // names one item where /V names two + ("/V [] /I [1]", 0, vec![]), // an empty value selects nothing + ("/V (9)", 0, vec![]), // no option exports 9 + ] { + let doc = duplicate_exports(multi, extra); + assert_eq!(field(&fields(&doc), "dup").selected, want, "{extra}"); + assert_eq!(shown_rows(&doc).len(), want.len(), "{extra}"); + } + // Pure: positions past the options or unlike the values are ignored. + let options = [("1".to_string(), "One-A".to_string()), ("1".to_string(), "One-B".to_string())]; + assert_eq!(selected_options(&options, &["1".into()], Some(&[1])), [1]); + assert_eq!(selected_options(&options, &["1".into()], Some(&[7])), [0]); + assert_eq!(selected_options(&options, &[], Some(&[])), Vec::::new()); +} + +/// Picking an option by position writes its export value to `/V` and its position to `/I`, so a +/// pick of One-A or One-B survives a save and reopen; picking by display text does the same. +#[test] +fn picking_one_of_two_options_with_the_same_export_value_round_trips() { + for (value, want) in [ + (FieldValue::ChoiceIndices(vec![0]), 0), + (FieldValue::ChoiceIndices(vec![1]), 1), + (FieldValue::Text("One-B".into()), 1), + (FieldValue::Text("One-A".into()), 0), + (FieldValue::Choice(vec!["1".into()]), 0), + ] { + let mut doc = duplicate_exports(0, "/V (1) /I [1]"); + set_value(&mut doc, "dup", &value).unwrap(); + let doc = reopen(&doc); + let dup = field(&fields(&doc), "dup").clone(); + let d = doc.get(dup.obj).as_dict().cloned().unwrap(); + assert_eq!(d.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("1"), "{value:?}: /V is one string"); + let i: Vec = d.get(b"I").and_then(|i| i.as_array()).unwrap().iter().filter_map(Object::as_int).collect(); + assert_eq!(i, [want as i64], "{value:?}"); + assert_eq!(dup.selected, [want], "{value:?}"); + assert_eq!(highlighted_rows(&ap(&doc, &dup.widgets[0])).len(), 1, "{value:?}: its saved look highlights one row"); + } + // The saved look highlights the row picked, not the first with its value. + let saved = |pick: usize| { + let mut doc = duplicate_exports(0, ""); + set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![pick])).unwrap(); + highlighted_rows(&ap(&doc, &field(&fields(&doc), "dup").widgets[0])) + }; + assert_ne!(saved(0), saved(1)); + // Positions are checked: out of range, or several in a single-select list. + let mut doc = duplicate_exports(0, ""); + assert!(matches!(set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![3])), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![usize::MAX])), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![0, 1])), Err(FormError::Invalid(_)))); + // A multi-select list takes both options that share the value, in ascending /I. + let mut doc = duplicate_exports(1 << 21, ""); + set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![1, 0])).unwrap(); + let doc = reopen(&doc); + let dup = field(&fields(&doc), "dup").clone(); + assert_eq!(dup.value, ["1", "1"]); + assert_eq!(dup.selected, [0, 1]); + assert_eq!(dup.picked_in_order(), [0, 1]); + // A check box can't take positions. + let mut doc = fixture(); + assert!(matches!(set_value(&mut doc, "agree", &FieldValue::ChoiceIndices(vec![0])), Err(FormError::Invalid(_)))); +} + +/// A file can hold a list box with very many options and values. Matching values to options +/// takes one lookup per value, not a scan of the options (or of the selection) for each, so +/// reading and showing such a field stays fast. Two options share each export value, and every +/// result still names the first option exporting a value, or the next one selected. +#[test] +fn many_options_and_values_match_in_linear_time() { + const N: usize = 100_000; + let options: Vec<(String, String)> = (0..2 * N).map(|i| (format!("e{}", i / 2), format!("d{i}"))).collect(); + let values: Vec = (0..N).rev().map(|k| format!("e{k}")).collect(); + let started = std::time::Instant::now(); + let selected = selected_options(&options, &values, None); + assert_eq!(selected, (0..N).map(|k| 2 * k).collect::>(), "the first option exporting each value"); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(2), "selected_options took {took:?}"); + + let mut list = field(&fields(&fixture()), "toppings").clone(); + list.options = options; + list.value = values; + list.selected = (0..2 * N).collect(); + let started = std::time::Instant::now(); + let order = list.picked_in_order(); + let shown = list.display_value(); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(2), "picked_in_order and display_value took {took:?}"); + // The values name e{N-1} first: its first option, then down to e0; the second option of + // each value is selected without a value naming it, so it follows in ascending order. + assert_eq!(order.first(), Some(&(2 * (N - 1)))); + assert_eq!(order.get(N - 1), Some(&0)); + assert_eq!(order.get(N..), Some(&(0..N).map(|k| 2 * k + 1).collect::>()[..])); + assert!(shown.starts_with(&format!("d{}, d{}", 2 * (N - 1), 2 * (N - 2))), "{}", shown.get(..40).unwrap_or(&shown)); +} + +/// A one-page form with list box `pair` (single select unless `ff` says otherwise) of three +/// countries, plus `extra` entries (`/V`, `/DV`, `/I`) on the field. +fn countries(ff: u32, extra: &str) -> Document { + let objs: Vec = 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 [6 0 R] >>".into(), + "<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Ff {ff} /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] {extra} /Rect [50 700 200 746] /P 3 0 R >>" + ), + ]; + document(&objs) +} + +/// ISO 32000-2 Table 233, MultiSelect: "if clear, at most one item shall be selected". A list box +/// without it given `/V [(TW) (JP)]` holds and shows Taiwan alone, and opening it changes nothing +/// in the file. +#[test] +fn a_single_select_list_given_several_values_shows_the_first() { + for (extra, multi, value, want) in [ + ("/V [(TW) (JP)]", 0, vec!["TW"], vec![0]), + ("/V [(JP) (TW)]", 0, vec!["JP"], vec![1]), + ("/V [(TW) (JP)] /I [0 1]", 0, vec!["TW"], vec![0]), // /I names more than the one value + ("/V [(XX) (JP)]", 0, vec!["XX"], vec![]), // the first value is no option + ("/V []", 0, vec![], vec![]), + ("/V [(TW) (JP)]", 1 << 21, vec!["TW", "JP"], vec![0, 1]), // multi-select keeps both + ] { + let doc = countries(multi, extra); + let pair = field(&fields(&doc), "pair").clone(); + assert_eq!(pair.value, value, "{extra}"); + assert_eq!(pair.selected, want, "{extra}"); + let copy = with_field_appearances(&doc).expect("left to the viewer"); + assert_eq!(highlighted_rows(&ap(©, &field(&fields(©), "pair").widgets[0])).len(), want.len(), "{extra}"); + // Reading and drawing the display copy leaves the field's own /V as it was. + let v = doc.get(pair.obj).as_dict().and_then(|d| d.get(b"V").cloned()).unwrap(); + assert!(matches!(v, Object::Array(_)), "{extra}: {v:?}"); + } + // Two options selected by /I alone in a single-select list: the first. + let doc = duplicate_exports(0, "/V (1) /I [0 1]"); + assert_eq!(field(&fields(&doc), "dup").selected, [0]); +} + +/// Picking a row in such a list writes one string to `/V` and that option's position to `/I`, +/// and Clear form goes back to the first value of an array `/DV` instead of refusing it. +#[test] +fn picking_in_a_single_select_list_given_several_values_writes_one() { + for (pick, export, index) in [(FieldValue::ChoiceIndices(vec![2]), "US", 2), (FieldValue::Choice(vec!["JP".into()]), "JP", 1)] { + let mut doc = countries(0, "/V [(TW) (JP)] /I [0 1]"); + set_value(&mut doc, "pair", &pick).unwrap(); + let doc = reopen(&doc); + let pair = field(&fields(&doc), "pair").clone(); + let d = doc.get(pair.obj).as_dict().cloned().unwrap(); + assert_eq!(d.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some(export), "{pick:?}: /V is one string"); + let i: Vec = d.get(b"I").and_then(|i| i.as_array()).unwrap().iter().filter_map(Object::as_int).collect(); + assert_eq!(i, [index], "{pick:?}"); + assert_eq!((pair.value, pair.selected), (vec![export.to_string()], vec![index as usize])); + } + let mut doc = countries(0, "/V (US) /DV [(JP) (TW)]"); + assert_eq!(reset(&mut doc, None).unwrap(), 1); + let pair = field(&fields(&doc), "pair").clone(); + assert_eq!((pair.value, pair.selected), (vec!["JP".to_string()], vec![1])); +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index e8615567b6..fc4263b345 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,5 +1,5 @@ use super::appearances::{declares_pdfa, need_appearances}; -use super::read::{acroform, field_flags, fields, page_refs}; +use super::read::{acroform, field_flags, fields, listed_indices, page_refs, selected_options}; use super::{ Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance, author, calc_order, flags, invalid, name_obj, scripting, @@ -133,7 +133,13 @@ fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String } d.remove(b"RV"); })?; - redraw(doc, twin, value, scripts) + // Drawn with the options the copied `/I` selects, not only the export values. + let mut shown = twin.clone(); + shown.value = value.to_vec(); + if matches!(twin.kind, FieldKind::Combo | FieldKind::List) { + shown.selected = selected_options(&twin.options, value, listed_indices(doc, &src, twin.options.len()).as_deref()); + } + redraw(doc, &shown, value, scripts) } FieldKind::PushButton | FieldKind::Signature => Ok(()), } @@ -237,7 +243,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + FieldValue::Choice(_) | FieldValue::ChoiceIndices(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), }; set_states(doc, f, on.then_some(state.as_str())) } @@ -262,37 +268,60 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } set_states(doc, f, choice.as_deref()) } - (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { - let vals: Vec = match value { - FieldValue::Choice(v) => v.clone(), - FieldValue::Text(t) if t.is_empty() => Vec::new(), - FieldValue::Text(t) => vec![t.clone()], - other => return invalid(format!("{:?} can't take {other:?}", f.name)), + (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_) | FieldValue::ChoiceIndices(_)) => { + let count = match value { + FieldValue::Choice(v) => v.len(), + FieldValue::ChoiceIndices(v) => v.len(), + _ => 1, }; - if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { + if count > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { return invalid(format!("{:?} takes a single value", f.name)); } - // Accept export values or display texts; free text only in editable combo boxes. + // Each value as its export value and, when it is one of the options, that option's + // position: two options can share an export value, and the position (`/I`) says + // which one is picked (ISO 32000-2 Table 234). let mut exports = Vec::new(); - for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { - Some((e, _)) => exports.push(e.clone()), - None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), - None if f.options.is_empty() => exports.push(v.clone()), - None => { - let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); - return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + let mut picked: Vec = Vec::new(); + match value { + FieldValue::ChoiceIndices(positions) => { + for &i in positions { + let Some((e, _)) = f.options.get(i) else { + return invalid(format!("{:?} has no option {} (it has {})", f.name, i.saturating_add(1), f.options.len())); + }; + exports.push(e.clone()); + picked.push(i); + } + } + _ => { + let vals: Vec = match value { + FieldValue::Choice(v) => v.clone(), + FieldValue::Text(t) if t.is_empty() => Vec::new(), + FieldValue::Text(t) => vec![t.clone()], + other => return invalid(format!("{:?} can't take {other:?}", f.name)), + }; + // Accept export values or display texts; free text only in editable combo boxes. + for v in &vals { + match f.options.iter().position(|(e, d)| e == v || d == v) { + Some(i) => { + exports.push(f.options.get(i).map(|(e, _)| e.clone()).unwrap_or_else(|| v.clone())); + picked.push(i); + } + None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), + None if f.options.is_empty() => exports.push(v.clone()), + None => { + let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); + return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + } + } } } } - let mut indices: Vec = - exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); - // /I lists option indices in ascending order; /V keeps the caller's value order. - // Every entry above is an integer, so this sorts by its numeric option index. - indices.sort_unstable_by_key(Object::as_int); + // /I lists option indices in ascending order, each once; /V keeps the caller's order. + picked.sort_unstable(); + picked.dedup(); + let indices: Vec = picked.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect(); // 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 picked: Vec = 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"); @@ -317,7 +346,10 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } })?; scroll_widgets(doc, f, &picked, f.options.len())?; - redraw(doc, f, &exports, scripts) + let mut shown = f.clone(); + shown.value = exports.clone(); + shown.selected = picked; + redraw(doc, &shown, &exports, scripts) } (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 7232b1169b..1cf6a1b610 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -259,31 +259,33 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b let zoom = (r.height() as f64) / height_pt; let y_pt = (p.y - r.min.y) as f64 / zoom; let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 }); - let picked_indices: Vec = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect(); - let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len()); + let top = form_appearance::ListRows::top_showing(rows.top, &f.selected, rows.visible(height_pt), f.options.len()); if let Some(opt_idx) = rows.option_at(top, y_pt).filter(|&i| i < f.options.len()) { - let export = &f.options[opt_idx].0; let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); let shift = ui.input(|i| i.modifiers.shift); let multi = f.has(field_flags::MULTI_SELECT); - let new_value = if multi && ctrl { - let mut picked = f.value.clone(); - if let Some(pos) = picked.iter().position(|x| x == export) { + // Options by position: two options can share an export value (ISO 32000-2 + // Table 234, `/I`), and the click picks the row, not the first option with its value. + let in_order = f.picked_in_order(); + let picked: Vec = if multi && ctrl { + let mut picked = in_order.clone(); + if let Some(pos) = picked.iter().position(|&x| x == opt_idx) { picked.remove(pos); } else { - picked.push(export.clone()); + picked.push(opt_idx); } - FieldValue::Choice(picked) - } else if multi && shift && !f.value.is_empty() { - let anchor = f.value.last().and_then(|last| f.options.iter().position(|(e, _)| e == last)).unwrap_or(opt_idx); + picked + } else if multi + && shift + && let Some(&anchor) = in_order.last() + { let (min, max) = (anchor.min(opt_idx), anchor.max(opt_idx)); - let picked = f.options[min..=max].iter().map(|(e, _)| e.clone()).collect(); - FieldValue::Choice(picked) + (min..=max).collect() } else { - FieldValue::Choice(vec![export.clone()]) + vec![opt_idx] }; - if new_value != FieldValue::Choice(f.value.clone()) { - view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: new_value }); + if picked != in_order { + view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::ChoiceIndices(picked) }); } } } @@ -597,19 +599,19 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, let zoom = (xf.rect.width() / unrotated_w.max(1.0)) as f64; let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 }); let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) }; - let picked_indices: Vec = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect(); - let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len()); + let top = form_appearance::ListRows::top_showing(rows.top, &f.selected, rows.visible(height_pt), f.options.len()); let font_size = (rows.size * zoom).clamp(6.0, 64.0) as f32; let row_h = (rows.row * zoom) as f32; let inset = (rows.inset * zoom) as f32; let p = painter.with_clip_rect(r); let mut y = r.min.y + inset; - for (export, display) in f.options.iter().skip(top) { + for (i, (_, display)) in f.options.iter().enumerate().skip(top) { if y + row_h > r.max.y { break; } let row_rect = Rect::from_min_size(egui::pos2(r.min.x + 1.0, y), egui::vec2(r.width() - 2.0, row_h)); - if f.value.contains(export) { + // By position: options can share an export value. + if f.selected.contains(&i) { p.rect_filled(row_rect, CornerRadius::ZERO, Color32::from_rgb(0x99, 0xC0, 0xDC)); } p.text( @@ -924,11 +926,11 @@ pub(crate) fn overlay( } else if tab { next = Some(!shift); } else if (up || down) && !f.options.is_empty() { - let current_idx = f.value.last().and_then(|v| f.options.iter().position(|(e, _)| e == v)).unwrap_or(0); - let next_idx = if up { current_idx.saturating_sub(1) } else { (current_idx + 1).min(f.options.len().saturating_sub(1)) }; + let current_idx = f.picked_in_order().last().copied().unwrap_or(0); + let next_idx = if up { current_idx.saturating_sub(1) } else { current_idx.saturating_add(1).min(f.options.len().saturating_sub(1)) }; if next_idx != current_idx && next_idx < f.options.len() { - let export = f.options[next_idx].0.clone(); - edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) }); + // By position, so the arrows step through options that share an export value. + edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::ChoiceIndices(vec![next_idx]) }); } } if clicked_outside() { diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index fa828a8f9f..f089b33729 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1294,6 +1294,98 @@ fn list_box_clicks_select_directly_and_ctrl_click_toggles_multi() { assert_eq!(value(&h, "ListMulti"), ["Tue", "Thu"], "Ctrl-clicking Thu toggled it on, keeping Tue"); } +/// The selected option positions of `name` (`/I`, or `/V` without a usable one). +fn selected(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().selected.clone() +} + +/// ISO 32000-2 Table 234: `/I` tells apart options that share an export value. A list box whose +/// One-A and One-B both export `1` shows the one `/I` names, and a click picks the row clicked: +/// `/V (1)` with `/I` naming it, so the pick survives a save and reopen. +#[test] +fn a_list_box_click_picks_one_of_two_options_with_the_same_export_value() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("dup.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert_eq!(selected(&h, "dup"), [1], "One-B is selected, not One-A too"); + + let r = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "dup").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom = r.height() / 70.0; + // Row 0 (One-A), then row 1 (One-B): each click changes the selection though /V stays (1). + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom)); + assert_eq!(value(&h, "dup"), ["1"]); + assert_eq!(selected(&h, "dup"), [0], "clicking One-A picks One-A"); + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 19.0 * zoom)); + assert_eq!(selected(&h, "dup"), [1], "clicking One-B picks One-B again"); + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom)); + assert_eq!(selected(&h, "dup"), [0]); + + let dir = scratch("duplicate-exports"); + let out = dir.join("saved.pdf"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + wait_for_file(&mut h, &out); + let mut app = PdfCraftApp::new(); + app.open_bytes("saved.pdf", None, std::fs::read(&out).unwrap()).unwrap(); + let reopened = app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "dup").unwrap().clone(); + assert_eq!(reopened.value, ["1"], "/V is the one export value"); + assert_eq!(reopened.selected, [0], "/I keeps One-A through the save"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// ISO 32000-2 Table 233: a list box without MultiSelect selects "at most one item". Given +/// `/V [(TW) (JP)]` it shows Taiwan alone, and a click writes the one option clicked. +#[test] +fn a_single_select_list_box_given_two_values_shows_and_takes_one() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pair.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert_eq!((value(&h, "pair"), selected(&h, "pair")), (vec!["TW".to_string()], vec![0]), "Taiwan alone"); + + let r = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "pair").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom = r.height() / 70.0; + // Row 1 (Japan), which /V also named: clicking it makes it the one value. + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 19.0 * zoom)); + assert_eq!((value(&h, "pair"), selected(&h, "pair")), (vec!["JP".to_string()], vec![1])); +} + /// Auto-size (`0 Tf`, inherited from the AcroForm) single-line and multiline fields, and a fixed 10 pt one. fn auto_size_form() -> Vec { pdf_from_objects(&[ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..1f095bed92 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4534,9 +4534,9 @@ tier = "P0" milestone = "M6" status = "shipped" commands = ["form.fields"] -tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/ui-egui/tests/it/forms.rs::an_editable_combo_box_takes_a_typed_value", "crates/ui-egui/tests/it/forms.rs::a_combo_box_without_edit_takes_no_typing", "crates/automation/tests/it/automation.rs::form_fill_takes_free_text_only_in_an_editable_combo_box"] -notes = "combo and list boxes, multi-select lists; an editable combo box (Edit flag) has a text box over the field as well as its list, and takes typed text on the page and through form_fill; a combo box without Edit takes only its options. No type-ahead or spell check in the typed box" +tools = ["form_fill", "form_fields"] +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/ui-egui/tests/it/forms.rs::an_editable_combo_box_takes_a_typed_value", "crates/ui-egui/tests/it/forms.rs::a_combo_box_without_edit_takes_no_typing", "crates/automation/tests/it/automation.rs::form_fill_takes_free_text_only_in_an_editable_combo_box", "crates/forms/src/tests.rs::a_list_box_with_duplicate_export_values_selects_the_option_i_names", "crates/forms/src/tests.rs::an_unusable_i_falls_back_to_the_first_option_with_the_value", "crates/forms/src/tests.rs::picking_one_of_two_options_with_the_same_export_value_round_trips", "crates/ui-egui/tests/it/forms.rs::a_list_box_click_picks_one_of_two_options_with_the_same_export_value", "crates/automation/tests/it/list_selection.rs::form_fields_reports_which_of_two_options_with_one_export_value_is_selected", "crates/forms/src/tests.rs::a_single_select_list_given_several_values_shows_the_first", "crates/forms/src/tests.rs::picking_in_a_single_select_list_given_several_values_writes_one", "crates/ui-egui/tests/it/forms.rs::a_single_select_list_box_given_two_values_shows_and_takes_one", "crates/automation/tests/it/list_selection.rs::form_fields_reports_one_value_for_a_single_select_list_given_two"] +notes = "combo and list boxes, multi-select lists; an editable combo box (Edit flag) has a text box over the field as well as its list, and takes typed text on the page and through form_fill; a combo box without Edit takes only its options. a list box whose options share an export value shows and picks the one /I names (ISO 32000-2 Table 234): /I is used when it is integers, ascending, in range and names the same export values as /V, otherwise /V decides and each value selects the first option exporting it; a click or arrow key picks the row by position and writes its export value to /V and its position to /I, and form_fields reports selected (1-based positions); a list box without MultiSelect given an array /V or /DV holds, shows and resets to the first value only (Table 233), leaving the file as it is until the field is filled, when /V becomes one string and /I its position. No type-ahead or spell check in the typed box; a combo box whose options share an export value still shows the first of them" [[feature]] id = "form.push-buttons"