diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b9..4db920976 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -787,6 +787,17 @@ impl Document { self.editable() && self.permissions().is_none_or(|p| p.modify()) } + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + /// Filling in form fields is allowed (Table 22, bits 6 and 9). pub fn allows_form_filling(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..d52ae144d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4675,6 +4675,25 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// A laid-out XFA field that doesn't scroll (`hScrollPolicy="off"`) opens as a text field with +/// DoNotScroll, and its text box says what fits it: what the editor takes no more than. +#[test] +fn an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let doc = s.get(id).unwrap(); + let field = |end: &str| doc.form.iter().find(|f| f.name.ends_with(end)).unwrap_or_else(|| panic!("no field {end}")); + assert!(field("code").has(field_flags::DO_NOT_SCROLL) && !field("note").has(field_flags::DO_NOT_SCROLL)); + let tb = doc.field_text_box(&field("code").name, 0).expect("a text box"); + assert!(tb.fits("A1B2") && !tb.fits(&"W".repeat(40)), "a 1-inch field at 10 pt"); + assert!(doc.field_text_box("no such field", 0).is_none() && doc.field_text_box(&field("code").name, 5).is_none()); +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d..9d5fb43c7 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -72,6 +72,100 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } +/// How a text field's widget lays its value out in [`field_appearance_drawn`], for asking +/// whether a value fits it ([`TextBox::fits`]): what a field that doesn't scroll (`DoNotScroll`, +/// ISO 32000-2 Table 228) still takes. Built from the same pieces the appearance uses: the `/DA` +/// font from the form's `/DR` (Helvetica where that font can't show the text), the room inside +/// the border and the 2 pt padding, the `/DA` size (or the automatic size), and wrapping for a +/// multi-line field; the two must change together. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBox { + /// The `/DA` font from `/DR`, when it is one the appearance can draw with. + font: Option, + /// Width and height inside the border and padding. + inner_w: f64, + inner_h: f64, + /// The `/DA` size; `None` for an automatic (`0 Tf`) size, which shrinks to fit. + size: Option, + multiline: bool, + password: bool, + /// A comb field: its cells, not its width, bound it (`/MaxLen`). + comb: bool, +} + +/// The [`TextBox`] widget `w` of text field `f` lays its value out in. +pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let pad = 2.0 + border_width(doc, &wd); + let multiline = f.has(flags::MULTILINE); + TextBox { + font: dr_font(doc, &da.font).map(|(_, enc)| enc), + inner_w: (place.layout_w - 2.0 * pad).max(1.0), + inner_h: place.layout_h - 2.0 * pad, + size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), + multiline, + password: f.has(flags::PASSWORD), + comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + } +} + +impl TextBox { + /// Whether `text` fits: on one line within the width, or, multi-line, wrapped within the + /// height, as the appearance draws it. An automatic size shrinks before text stops fitting, + /// so such a field is full only when the text no longer fits at its smallest size (4 pt). A + /// comb field always fits (its `/MaxLen` bounds it). + pub fn fits(&self, text: &str) -> bool { + if self.comb || text.is_empty() { + return true; + } + let shown: String = if self.password { "*".repeat(text.chars().count()) } else { text.to_string() }; + // The font the appearance would choose for this text. + let can_show = |enc: &FontText| if self.multiline { shown.split(['\n', '\r']).all(|l| enc.can_encode(l)) } else { enc.can_encode(&shown) }; + let font = self.font.as_ref().filter(|enc| can_show(enc)).unwrap_or(&FontText::WinAnsi); + let width_of = |t: &str, size: f64| font.width(t, size); + // The smallest an automatic size goes ([`auto_text_size_by`]). + let size = self.size.unwrap_or(4.0); + if self.multiline { + let lines = wrap_with(&shown, size, self.inner_w, width_of).len() as f64; + lines * size * 1.15 <= self.inner_h + 1e-6 + } else { + // A line break in a single-line value is drawn as a space. + let line: String = shown.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); + width_of(&line, size) <= self.inner_w + 1e-6 + } + } + + /// An edit from `before` to `after` as a field that doesn't scroll takes it: all of it when + /// `after` fits, and otherwise as much of the text it inserted (typed or pasted) as still + /// fits, keeping what it removed removed. Returns the text and where the caret goes (in + /// characters, after what was kept of the insertion). + pub fn fitted_edit(&self, before: &str, after: &str) -> (String, usize) { + let (b, a): (Vec, Vec) = (before.chars().collect(), after.chars().collect()); + // The edit replaced b[p..b.len() - q] with a[p..a.len() - q]. + let p = b.iter().zip(&a).take_while(|(x, y)| x == y).count(); + let most = b.len().min(a.len()).saturating_sub(p); + let q = b.iter().rev().zip(a.iter().rev()).take(most).take_while(|(x, y)| x == y).count(); + // q <= a.len() - p, so these are in range; `get` keeps it so if that ever changes. + let end = a.len().saturating_sub(q); + let (prefix, inserted, suffix) = (a.get(..p).unwrap_or_default(), a.get(p..end).unwrap_or_default(), a.get(end..).unwrap_or_default()); + let with = |k: usize| prefix.iter().chain(inserted.get(..k).unwrap_or_default()).chain(suffix).collect::(); + if self.fits(after) { + return (after.to_string(), p + inserted.len()); + } + // The most of the insertion that fits: text only grows as it gets longer, so halve. + let (mut lo, mut hi) = (0, inserted.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + (with(lo), p + lo) + } +} + /// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90. pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 { let Some(raw) = wd.get(b"MK") else { return 0 }; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67..32eea6631 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,91 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// What fits a text field is measured as its appearance draws it: in the field's own font (its +/// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its +/// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, +/// and without a limit for a comb field. +#[test] +fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { + // Each widget is 100 x 20 pt (multi-line: 100 x 40 pt) with no border: 96 pt inside the 2 pt + // padding (36 pt high for the multi-line one). + let tx = |name: &str, da: &str, ff: u32, extra: &str, top: i32, h: i32| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA ({da}) /Ff {ff}{extra} /Rect [50 {} 150 {top}] /P 3 0 R >>", top - h) + }; + 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 [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Cour 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + tx("one", "/Helv 12 Tf 0 g", 0, "", 780, 20), + tx("auto", "/Helv 0 Tf 0 g", 0, "", 750, 20), + tx("many", "/Helv 12 Tf 0 g", flags::MULTILINE, "", 720, 40), + tx("cour", "/Cour 12 Tf 0 g", 0, "", 670, 20), + tx("pin", "/Helv 12 Tf 0 g", flags::PASSWORD, "", 640, 20), + tx("comb", "/Helv 12 Tf 0 g", flags::COMB, " /MaxLen 5", 610, 20), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + let helv = |s: &str, size: f64| pdfcraft_fonts::helvetica_width(s, size); + // Single line at 12 pt: as wide as the 96 pt inside, and no wider. + let fits_12: String = std::iter::repeat_n('W', (96.0 / helv("W", 12.0)) as usize).collect(); + assert!(tb("one").fits(&fits_12) && !tb("one").fits(&format!("{fits_12}W")), "{}", fits_12.len()); + // An automatic size shrinks first: full only when 4 pt no longer fits. + let fits_4: String = std::iter::repeat_n('W', (96.0 / helv("W", 4.0)) as usize).collect(); + assert!(!tb("one").fits(&fits_4) && tb("auto").fits(&fits_4) && !tb("auto").fits(&format!("{fits_4}W"))); + // Multi-line: lines of 12 x 1.15 pt each in 36 pt, so two lines fit and three don't. + assert!(tb("many").fits("one\ntwo") && !tb("many").fits("one\ntwo\nthree")); + let two_lines_of_words = "word ".repeat(((96.0 / helv("word ", 12.0)) as usize) * 2); + assert!(tb("many").fits(two_lines_of_words.trim_end()) && !tb("many").fits(&format!("{two_lines_of_words}word"))); + // The field's own font: narrow i's that Helvetica fits are too wide in Courier (600/1000 em). + let i_s: String = std::iter::repeat_n('i', 14).collect(); + assert!(tb("one").fits(&i_s) && !tb("cour").fits(&i_s), "Courier is 0.6 em a character: {}", 14.0 * 0.6 * 12.0); + // A password is drawn, so measured, as asterisks. + let thirty_i: String = std::iter::repeat_n('i', 30).collect(); + assert!(tb("one").fits(&thirty_i) && !tb("pin").fits(&thirty_i), "{} pt of asterisks", helv(&"*".repeat(30), 12.0)); + // A comb field's cells bound it, not its width. + assert!(tb("comb").fits(&"W".repeat(50))); +} + +/// An edit to a field that doesn't scroll keeps as much of what it inserted as fits, wherever the +/// caret was, and always keeps a deletion. +#[test] +fn an_edit_to_a_full_field_keeps_what_fits() { + 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 [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Helv 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // Typing in the middle of "Short": the X's go in until the field is full, the end stays. + let typed = format!("Sh{}ort", "X".repeat(40)); + let (kept, caret) = tb.fitted_edit("Short", &typed); + let xs = kept.chars().filter(|c| *c == 'X').count(); + assert!(kept.starts_with("ShX") && kept.ends_with("Xort") && xs < 40, "{kept}"); + assert!(tb.fits(&kept) && !tb.fits(&format!("Sh{}ort", "X".repeat(xs + 1))), "as many as fit: {xs}"); + assert_eq!(caret, 2 + xs, "the caret after the last X kept"); + // What fits is kept whole. + assert_eq!(tb.fitted_edit("Short", "Shorter"), ("Shorter".to_string(), 7)); + // A deletion is kept even from text that already overflows (set by a script, say). + let long = "W".repeat(20); + assert!(!tb.fits(&long)); + assert_eq!(tb.fitted_edit(&long, &"W".repeat(19)), ("W".repeat(19), 19)); + // Replacing a selection with too much keeps the removal and what fits of the replacement. + let (kept, _) = tb.fitted_edit("Short", &format!("S{}t", "W".repeat(30))); + assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1af2a6a4f..5e52d1d98 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4204,7 +4204,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) { + let text_box = crate::forms_ui::text_box(doc, view, &form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 76e092672..0de859a7d 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -516,8 +516,26 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, } } +/// How the focused text field lays text out, when it is one that doesn't scroll (`DoNotScroll`): +/// its editor then takes no more than fits ([`overlay`]). +pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[FormField]) -> Option { + let fx = view.forms.focus.as_ref()?; + let f = form.iter().find(|f| f.name == fx.name)?; + let no_scroll = f.kind == FormFieldKind::Text && f.has(field_flags::DO_NOT_SCROLL); + no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. -pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField], today: (i64, u32, u32)) -> Option { +/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor +/// then takes no more than fits. +pub(crate) fn overlay( + ctx: &egui::Context, + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + text_box: Option<&form_appearance::TextBox>, + today: (i64, u32, u32), +) -> Option { let pressed = view.forms.pressed; if !ctx.input(|i| i.pointer.primary_down()) { view.forms.pressed = false; @@ -566,6 +584,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). + let before = text_box.map(|_| fx.text.clone()); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te // Wraps (inside the 3 px side margins) at the width the size was fitted to: @@ -589,6 +609,19 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f laid_out = Some((out.galley, out.galley_pos)); out.response.response }); + if let (Some(tb), Some(before)) = (text_box, before) + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } if let Some(press) = fx.press.take() && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd65..fee012a11 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -105,6 +105,75 @@ fn a_blank_tooltip_shows_nothing() { assert!(egui::Tooltip::seconds_since_last_tooltip(&h.ctx).is_infinite(), "no tooltip was shown"); } +/// A text field that doesn't scroll (`DoNotScroll`) takes no more than it shows, measured as its +/// appearance draws it: a long paste keeps what fits, typing into a full field adds nothing, and +/// a field without the flag takes it all. +#[test] +fn a_field_that_doesnt_scroll_stops_taking_text_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], font_size: Some(12.0), ..Default::default() }, + ); + let text_box = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let typing = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + let kept = typing(&h); + let tb = text_box(&h); + assert!(!kept.is_empty() && kept.len() < 80, "{} of 80 kept", kept.len()); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}W")), "as much as fits: {}", kept.len()); + h.event(egui::Event::Text("WWW".into())); + h.run_steps(2); + assert_eq!(typing(&h), kept, "a full field takes no more"); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "city"), [kept]); + // A field without the flag takes the whole paste. + click_field(&mut h, "name", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "name"), ["W".repeat(80)]); +} + +/// A multi-line field that doesn't scroll, with an automatic size, takes lines until even the +/// smallest size it shrinks to can't show them all. +#[test] +fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { + flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], + multiline: Some(true), + font_size: Some(0.0), + ..Default::default() + }, + ); + let da = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().da.clone() + }; + assert!(da.contains(" 0 Tf"), "an automatic size: {da}"); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("line\n".repeat(60))); + h.run_steps(2); + let kept = h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + let tb = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let lines = kept.lines().count(); + assert!(lines > 1 && lines < 60, "{lines} of 60 lines kept"); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e..8efee1029 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -126,6 +126,8 @@ pub struct Widget { pub action: Option, /// Check boxes: the on and off values of the data; radio buttons: the on value. pub items: Vec, + /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). + pub no_scroll: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -1804,6 +1806,7 @@ impl Layouter<'_> { default: template_default, action, items, + no_scroll: f.no_scroll, }))) } } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7f..793950bfb 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -486,6 +486,9 @@ pub struct Field { pub check_size: Option, pub check_round: bool, pub multiline: bool, + /// The field doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for one + /// line, `vScrollPolicy="off"` for several): it takes no more text than it shows. + pub no_scroll: bool, pub caption: Option, pub value: Value, /// Check buttons: the on value then the off value; choice lists: the items as shown. diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a24..500465168 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -478,6 +478,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { } _ => ChoiceList::default(), }; + let multiline = ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")); let validate_el = child(n, "validate"); let validate_message = validate_el .and_then(|v| child(v, "message")) @@ -492,7 +493,8 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))), check_size: ui_el.and_then(|e| attr_measure(e, "size")).filter(|s| *s > 0.0), check_round: ui_el.is_some_and(|e| e.attribute("shape") == Some("round")), - multiline: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")), + multiline, + no_scroll: ui_el.is_some_and(|e| e.attribute(if multiline { "vScrollPolicy" } else { "hScrollPolicy" }) == Some("off")), caption: parse_caption(child(n, "caption"), warnings), value: parse_value(value_el, warnings), items, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d88..c2d71ad91 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -244,6 +244,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); @@ -285,6 +287,10 @@ impl Emitter<'_> { if w.kind == WidgetKind::Password { ff |= 1 << 13; } + // DoNotScroll (ISO 32000-2 Table 228): the field takes no more than it shows. + if w.no_scroll { + ff |= 1 << 23; + } if let Some(m) = w.max_chars { d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64)); } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b17..1d3f541e4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,45 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for +/// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it +/// takes no more text than it shows; the other direction's policy, or the default, doesn't. +#[test] +fn fields_that_dont_scroll_are_written_with_do_not_scroll() { + let field = |name: &str, ui: &str| format!(r#"{ui}"#); + let xdp = format!( + r##" +"##, + field("line", r#""#), + field("lines", r#""#), + field("number", r#""#), + field("when", r#""#), + field("linesAcross", r#""#), + field("lineDown", r#""#), + field("free", r#""#), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone(); + let mut no_scroll = std::collections::BTreeMap::new(); + for r in fields.iter().filter_map(Object::as_ref) { + let f = doc.get(r); + let d = f.as_dict().unwrap(); + let name = d.get(b"T").and_then(Object::as_string).map(|t| t.to_text()).unwrap_or_default(); + no_scroll.insert(name, d.int(b"Ff").unwrap_or(0) & (1 << 23) != 0); + } + let expected: std::collections::BTreeMap = + [("line", true), ("lines", true), ("number", true), ("when", true), ("linesAcross", false), ("lineDown", false), ("free", false)] + .into_iter() + .map(|(n, v)| (n.to_string(), v)) + .collect(); + assert_eq!(no_scroll, expected); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..f2e6632e5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4440,8 +4440,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." [[feature]] id = "form.fill-checkbox-radio"