diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03b3e5a92..af5718b57 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,7 +57,7 @@ pub use pdfcraft_edit::{ pub use pdfcraft_forms::{ BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - flags as field_flags, + appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, }; pub use pdfcraft_a11y as a11y; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index dc5ffcef1..6cf90daa3 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -38,6 +38,39 @@ pub fn fmt(v: f64) -> String { n(v) } +/// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box +/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt. +/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose +/// wrapped lines fit the height. The page appearance and the on-screen editor share this. +pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 { + let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 }; + auto_text_size_by( + width, + height, + multiline, + |size| width_of(text, size), + |size| wrap_with(text, size, wrap_width, &width_of).len() as f64 * size * 1.15, + ) +} + +/// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on +/// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its +/// own font, whose widths and line breaks differ from the page's. +pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: impl Fn(f64) -> f64, wrapped_height: impl Fn(f64) -> f64) -> f64 { + let (width, height) = (if width.is_finite() { width.max(1.0) } else { 1.0 }, if height.is_finite() { height } else { 0.0 }); + if multiline { + let mut size = 12.0; + // A NaN height from the measurer compares false, so it keeps the size it has. + while size > 4.0 && wrapped_height(size) > height { + size -= 0.5; + } + return size; + } + let size = (height / 1.15).clamp(4.0, 12.0); + let tw = line_width(size); + if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } +} + /// `/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 }; @@ -389,11 +422,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { - // Auto size: the largest size (≤ 12) whose wrapped lines fit the height. - size = 12.0; - while size > 4.0 && wrap_with(&text, size, inner_w, width_of).len() as f64 * size * 1.15 > height - 2.0 * pad { - size -= 0.5; - } + size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } let mut y = height - pad - size * 0.85; for line in wrap_with(&text, size, inner_w, width_of) { @@ -405,11 +434,12 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } } else { if size == 0.0 { - size = ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0); - let tw = width_of(&text, size); - if tw > inner_w && !comb { - size = (size * inner_w / tw).max(4.0); - } + // Comb cells hold one character each, so only the height limits them. + size = if comb { + ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0) + } else { + auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) + }; } // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. let y = (height - 0.925 * size) / 2.0 + 0.207 * size; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d434957eb..31340f9dd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1971,3 +1971,29 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); assert!(filled.rules.is_empty()); } + +#[test] +fn auto_text_size_fits_single_and_multiline_text() { + use crate::appearance::auto_text_size; + use pdfcraft_fonts::{helvetica_width, wrap_with}; + let long = "Weekly progress and diligence: CoBank refi, IL and Redwood Falls"; + // Single line: the height's size for short text, shrunk to the width for long text. + assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15); + let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width); + assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}"); + // Never past 12 pt or under 4 pt. + assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0); + assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0); + // Multiline: the largest size whose wrapped lines fit the height. + let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width); + assert!(wrap_with(long, m, 114.0, helvetica_width).len() as f64 * m * 1.15 <= 44.0, "{m}"); + assert!(wrap_with(long, m + 0.5, 114.0, helvetica_width).len() as f64 * (m + 0.5) * 1.15 > 44.0, "{m} is the largest that fits"); + // Hostile boxes and measurements don't panic or return nonsense. + for (w, h) in [(f64::NAN, 10.0), (10.0, f64::INFINITY), (-5.0, -5.0), (0.0, 0.0)] { + for multiline in [false, true] { + let s = auto_text_size(long, w, h, multiline, helvetica_width); + assert!((4.0..=12.0).contains(&s), "{w}×{h}: {s}"); + } + } + assert_eq!(auto_text_size(long, 50.0, 10.0, false, |_, _| f64::NAN), 10.0 / 1.15); +} diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 339389b18..e95f4f0eb 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -282,6 +282,35 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, } } +/// The size, in points, a text field shows `text` at on screen: its `/DA` size, or for auto size +/// (`0 Tf`) the page appearance's fit, measured in the font the screen draws with so the text +/// stays inside the box (the interface font runs wider than the page's Helvetica). +pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, text: &str) -> f32 { + let da = f.da.split_whitespace().collect::>(); + let fixed = da + .iter() + .position(|x| *x == "Tf") + .and_then(|i| i.checked_sub(1)) + .and_then(|i| da.get(i)) + .and_then(|s| s.parse::().ok()) + .filter(|s| s.is_finite() && *s > 0.0); + let size = fixed.unwrap_or_else(|| { + // The appearance's padding: 2 pt inside a 1 pt border. + let pad = 3.0; + let (width, height) = ((w.rect[2] - w.rect[0]).abs() - 2.0 * pad, (w.rect[3] - w.rect[1]).abs() - 2.0 * pad); + // Comb cells hold one character each, so only the height limits them. + let comb = f.has(field_flags::COMB) && f.max_len.is_some_and(|m| m > 0); + let fit = if comb { "" } else { text }; + let font = |size: f64| egui::FontId::proportional(size as f32); + let line_width = |size: f64| f64::from(painter.layout_no_wrap(fit.to_string(), font(size), Color32::BLACK).size().x); + // Wrapped as the editor and the page preview wrap it. + let wrap = width.max(1.0) as f32; + let wrapped_height = |size: f64| f64::from(painter.layout(fit.to_string(), font(size), Color32::BLACK, wrap).size().y); + pdfcraft_engine::field_auto_text_size_by(width, height, f.has(field_flags::MULTILINE), line_width, wrapped_height) + }); + size.min(300.0) as f32 +} + fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) { match f.kind { FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => { @@ -291,14 +320,7 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, if !text.is_empty() { let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw }; let zoom = xf.rect.width() / unrotated_w.max(1.0); - let da = f.da.split_whitespace().collect::>(); - let size = da - .iter() - .position(|x| *x == "Tf") - .and_then(|i| da.get(i.wrapping_sub(1))) - .and_then(|s| s.parse::().ok()) - .filter(|s| *s > 0.0); - let font_size = (size.unwrap_or(((w.rect[3] - w.rect[1]) as f32 * 0.6).clamp(6.0, 12.0)) * zoom).clamp(6.0, 64.0); + let font_size = (field_text_size(painter, f, w, text) * zoom).clamp(3.0, 64.0); let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() }; let p = painter.with_clip_rect(r); let (align, pos) = match f.quadding { @@ -306,7 +328,11 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, 2 => (egui::Align2::RIGHT_TOP, egui::pos2(r.max.x - 3.0, r.min.y + 1.0)), _ => (egui::Align2::LEFT_TOP, egui::pos2(r.min.x + 3.0, r.min.y + 1.0)), }; - p.text(pos, align, display_text, egui::FontId::proportional(font_size), Color32::BLACK); + // Multiline fields wrap at the box, as their page appearance does. + // At the width the size was fitted to: the box less 3 pt each side, scaled. + let wrap = if f.has(field_flags::MULTILINE) { (r.width() - 6.0 * zoom).max(1.0) } else { f32::INFINITY }; + let galley = p.layout(display_text, egui::FontId::proportional(font_size), Color32::BLACK, wrap); + p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); } } FormFieldKind::CheckBox => { @@ -349,19 +375,19 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let mut next: Option = None; // Tab / Shift+Tab match f.kind { FormFieldKind::Text => { - let da = f.da.split_whitespace().collect::>(); - let size = - da.iter().position(|x| *x == "Tf").and_then(|i| da.get(i.wrapping_sub(1))).and_then(|s| s.parse::().ok()).filter(|s| *s > 0.0); let multiline = f.has(field_flags::MULTILINE); - let font = (size.unwrap_or(((w.rect[3] - w.rect[1]) as f32 * 0.6).clamp(6.0, 12.0)) * zoom).clamp(6.0, 64.0); egui::Area::new(egui::Id::new(("form-editor", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.min).show(ctx, |ui| { ui.set_min_size(rect.size()); let Some(fx) = view.forms.focus.as_mut() else { return }; + // Sized from the text being typed, so an auto-sized field shrinks to keep it in view. + 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); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te - .desired_width(rect.width() - 6.0) + // Wraps (inside the 3 px side margins) at the width the size was fitted to: + // the box less 3 pt each side, scaled. + .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 34e3300c2..89bd0b850 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -701,3 +701,69 @@ fn the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced() { h.get_by_label("Unplaced"); assert_eq!(h.query_all_by_label("orphan").count(), 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(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (short) /Rect [20 350 220 364] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Ff 4096 /Rect [20 270 140 320] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (fixed) /DA (/Helv 10 Tf 0 g) /Rect [20 220 120 234] /P 3 0 R /F 4 >>", + ]) +} + +fn auto_size_harness() -> Harness<'static, PdfCraftApp> { + 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("auto.pdf", None, auto_size_form()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + h +} + +/// The on-screen size for `text` in the field, and how wide (single-line) or tall (multiline, +/// wrapped at the box) that text is in the screen font, against the room inside the box (pt). +fn on_screen_fit(h: &Harness<'static, PdfCraftApp>, name: &str, text: &str) -> (f32, f32, f32) { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().clone(); + let w = f.widgets[0].clone(); + let painter = egui::Painter::new(h.ctx.clone(), egui::LayerId::background(), egui::Rect::EVERYTHING); + let size = pdfcraft_ui_egui::forms_ui::field_text_size(&painter, &f, &w, text); + let (bw, bh) = ((w.rect[2] - w.rect[0]) as f32 - 6.0, (w.rect[3] - w.rect[1]) as f32 - 6.0); + let font = egui::FontId::proportional(size); + if f.has(pdfcraft_engine::field_flags::MULTILINE) { + (size, painter.layout(text.to_string(), font, egui::Color32::BLACK, bw).size().y, bh) + } else { + (size, painter.layout_no_wrap(text.to_string(), font, egui::Color32::BLACK).size().x, bw) + } +} + +#[test] +fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { + let mut h = auto_size_harness(); + let long = "Weekly progress and diligence: CoBank refi, IL and Redwood Falls"; + click_field(&mut h, "short", 0); + h.event(egui::Event::Text(long.into())); + h.run_steps(2); + let typed = h.state().views[0].forms.focus.clone().expect("the editor is open").text; + assert_eq!(typed, long); + let (size, used, room) = on_screen_fit(&h, "short", &typed); + assert!(size > 4.0 && size < 8.0 / 1.15, "a long value shrinks in the short box: {size} pt"); + assert!(used <= room + 0.5, "and fits its width: {used} > {room}"); + let (short_size, ..) = on_screen_fit(&h, "short", "Hi"); + assert!(short_size > size, "short text keeps a larger size: {short_size}"); + + let (size, used, room) = on_screen_fit(&h, "notes", long); + assert!(used <= room + 0.5, "multiline text wraps and shrinks to the box height: {used} > {room} at {size} pt"); + assert!(size >= 6.0, "without shrinking further than it must: {size} pt"); + + // An explicit size is kept, as in Acrobat: the text may then run past the box. + assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a3e4cbaef..fd9aa770d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4404,8 +4404,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "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/automation.rs::orphan_form_fields_through_tools"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/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/automation.rs::orphan_form_fields_through_tools"] +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)" [[feature]] id = "form.fill-checkbox-radio" @@ -4464,7 +4464,7 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"] +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"] notes = "auto-size, comb, multiline, password, quadding, /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" [[feature]]