mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:17:44 -06:00
M6: fields that don't scroll stop taking text when full
This commit is contained in:
@@ -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<form_appearance::TextBox> {
|
||||
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())
|
||||
|
||||
@@ -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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<field name="code" w="1in" h="0.3in"><ui><textEdit hScrollPolicy="off"/></ui><font typeface="Arial" size="10pt"/></field>
|
||||
<field name="note" w="1in" h="0.3in"><ui><textEdit/></ui><font typeface="Arial" size="10pt"/></field>
|
||||
</subform></template></xdp:xdp>"##;
|
||||
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)));
|
||||
|
||||
@@ -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<FontText>,
|
||||
/// 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<f64>,
|
||||
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<char>, Vec<char>) = (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::<String>();
|
||||
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 };
|
||||
|
||||
@@ -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<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 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<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 [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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<form_appearance::TextBox> {
|
||||
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<Edit> {
|
||||
/// `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<Edit> {
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -126,6 +126,8 @@ pub struct Widget {
|
||||
pub action: Option<Action>,
|
||||
/// Check boxes: the on and off values of the data; radio buttons: the on value.
|
||||
pub items: Vec<String>,
|
||||
/// 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,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +486,9 @@ pub struct Field {
|
||||
pub check_size: Option<f64>,
|
||||
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<Caption>,
|
||||
pub value: Value,
|
||||
/// Check buttons: the on value then the off value; choice lists: the items as shown.
|
||||
|
||||
@@ -478,6 +478,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> 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<String>) -> 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,
|
||||
|
||||
@@ -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<Option<ObjRef>, 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));
|
||||
}
|
||||
|
||||
@@ -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#"<field name="{name}" w="2in" h="0.4in"><ui>{ui}</ui></field>"#);
|
||||
let xdp = format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
{}{}{}{}{}{}{}
|
||||
</subform></template></xdp:xdp>"##,
|
||||
field("line", r#"<textEdit hScrollPolicy="off"/>"#),
|
||||
field("lines", r#"<textEdit multiLine="1" vScrollPolicy="off"/>"#),
|
||||
field("number", r#"<numericEdit hScrollPolicy="off"/>"#),
|
||||
field("when", r#"<dateTimeEdit hScrollPolicy="off"/>"#),
|
||||
field("linesAcross", r#"<textEdit multiLine="1" hScrollPolicy="off"/>"#),
|
||||
field("lineDown", r#"<textEdit vScrollPolicy="off"/>"#),
|
||||
field("free", r#"<textEdit/>"#),
|
||||
);
|
||||
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<String, bool> =
|
||||
[("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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user