From ccb2565036dd9845e53af30cd665953976c61f9c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 16:16:07 -0400 Subject: [PATCH 1/3] fix(forms): auto-sized single-line text fields fill their height, not 12 pt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An auto-sized (`0 Tf`) single-line text field drew at most 12 pt however tall the box. Acrobat Reader draws it at the size the height gives (checked black-box on a synthetic form: 30 pt and 60 pt fields); ISO 32000-2 §12.7.4.3 leaves the size implementation-defined. auto_text_size_by now keeps the existing height / 1.15 size without the 12 pt cap, still shrinks to the width and never goes under 4 pt, with the height bounded at 14,400 (MAX_AUTO_HEIGHT, the largest page side ISO 32000-1 Annex C recommends). Multiline and comb auto-size keep their 12 pt cap: not yet checked against Acrobat. Tests: the forms unit tests now pin a tall box's size (they pinned 12 pt), and an automation test fills a 30 pt field through form_fill and measures the drawn text with text_find. All three fail with the cap restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/automation.rs | 23 +++++++++++++++++++++++ crates/forms/src/appearance.rs | 14 ++++++++++---- crates/forms/src/tests.rs | 14 ++++++++++---- parity/acrobat-features.toml | 4 ++-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9aa..aa472ae975 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2548,6 +2548,29 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat +/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text. +#[test] +fn auto_sized_text_follows_a_tall_field() { + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3 + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6 + ]; + let dir = workdir("auto-size-tall"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } })); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" })); + let r = &found["matches"][0]["rects"][0]; + let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap(); + assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})"); +} + #[test] fn reversing_pages_through_tools() { let dir = workdir("reverse"); diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c1bb5630a6..694cca9bec 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String { } /// 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. +/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text +/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is +/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline +/// text takes the largest size up to 12 pt 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( @@ -54,6 +56,10 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side +/// ISO 32000-1 Annex C recommends. +pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; + /// [`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. @@ -67,7 +73,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i } return size; } - let size = (height / 1.15).clamp(4.0, 12.0); + let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0); let tw = line_width(size); if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e0..59321d02d4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1796,7 +1796,7 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's - // Auto size: 12 pt where there is room, smaller where there isn't. + // Auto size: the size the box's height gives, smaller where the text is too wide. let mut doc = one_page(); let tf_size = |ap: &str| { ap.split("/Helv ") @@ -1815,7 +1815,9 @@ fn new_text_fields_fit_their_box() { } add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); - assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15. + let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])); + assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}"); // An explicit size still wins. set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); @@ -3158,8 +3160,12 @@ fn auto_text_size_fits_single_and_multiline_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); + // A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there), + // bounded by the largest page; never under 4 pt. + assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15); + assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15); + let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width); + assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}"); 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); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..490f411180 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4547,8 +4547,8 @@ 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::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/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] -notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" +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/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "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/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] +notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" [[feature]] id = "form.rich-text-fields" From 0e559fca41dbefaf8347766eacf144686a6924d3 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 26 Oct 2026 19:27:44 -0400 Subject: [PATCH 2/3] fix(ui-egui): the field editor sizes auto-size text as the page does Review on #4862 found two mismatches between the on-page field editor and the field's appearance: - Comb fields: the editor sized them as single-line text (20.87 pt for a 500 x 30 comb) while the appearance uses 4-12 pt from the cell height. The comb test is now `Field::is_comb` and the comb size `appearance::comb_auto_text_size`, both shared by the appearance and `forms_ui::field_text_size`. The editor's test was looser (it ignored Multiline and Password); it now matches the page's. - Tall fields: `field_text_size` capped at 300 pt and the three call sites clamped the screen font to 64. Both are gone; `field_screen_font` scales by zoom and bounds the result at MAX_SCREEN_FONT (256 screen points) so a glyph stays well inside the font atlas. A non-finite comb height now gives 4 pt, as single-line auto-size does. Test: it::forms::the_field_editor_sizes_auto_size_text_as_the_page_does. With the comb change reverted the comb assertion fails (20.87 vs 12); with the 300/64 caps restored the 100 pt assertion fails. Also fixes it::forms::dragging_over_another_fields_text_focuses_it_and_selects_from_the_press, which the previous commit broke: the name field now auto-sizes to 13.9 pt instead of 12, so the press point 30 px in fell on "A|da"; it is now 38 px in, inside "Ad|a" as the test intends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/engine/src/reexports.rs | 2 +- crates/forms/src/appearance.rs | 14 ++++++--- crates/forms/src/lib.rs | 6 ++++ crates/ui-egui/src/forms_ui.rs | 30 ++++++++++++------ crates/ui-egui/tests/it/forms.rs | 52 ++++++++++++++++++++++++++++++-- 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs index 208a8e450b..2f4313eee8 100644 --- a/crates/engine/src/reexports.rs +++ b/crates/engine/src/reexports.rs @@ -20,7 +20,7 @@ pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, 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, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, + appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, 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 694cca9bec..b6fedbd55c 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -56,6 +56,13 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside +/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance +/// and the on-screen editor share this. +pub fn comb_auto_text_size(height: f64) -> f64 { + if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 } +} + /// The tallest box single-line auto-size follows, as a sanity bound: the largest page side /// ISO 32000-1 Annex C recommends. pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; @@ -115,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { 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), + comb: f.is_comb(), } } @@ -819,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } _ => { - let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); + let comb = f.is_comb(); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); @@ -846,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } else { if size == 0.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) + comb_auto_text_size(height - 2.0 * pad) } else { auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 39cebd8fdd..1ac75adb78 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -155,6 +155,12 @@ impl Field { self.flags & flag != 0 } + /// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO + /// 32000-2 Table 228). Each cell holds one character. + pub fn is_comb(&self) -> bool { + self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0) + } + pub fn read_only(&self) -> bool { self.has(flags::READ_ONLY) } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 28494160c5..4138f4e903 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -331,7 +331,7 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: .show(ctx, |ui| { ui.set_min_size(rect.size()); let Some(fx) = view.forms.focus.as_mut() else { return }; - let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom); // The box is pale yellow in every theme, as a text field's is (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); let te = egui::TextEdit::singleline(&mut fx.text) @@ -523,17 +523,29 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t // 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 }; + // Comb cells hold one character each, so only the height limits them, as on the page. + if f.is_comb() { + return pdfcraft_engine::field_comb_auto_text_size(height); + } 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); + let line_width = |size: f64| f64::from(painter.layout_no_wrap(text.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); + let wrapped_height = |size: f64| f64::from(painter.layout(text.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 + size as f32 +} + +/// The largest font, in screen points, the on-page field editor draws with: a bound that keeps +/// one rasterized glyph (font size times pixels per point) well inside egui's font atlas. +pub const MAX_SCREEN_FONT: f32 = 256.0; + +/// The font, in screen points, a field's editor shows `text` at, `zoom` screen points per page +/// point: [`field_text_size`] scaled, within 3 and [`MAX_SCREEN_FONT`]. +pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, text: &str, zoom: f32) -> f32 { + let font = field_text_size(painter, f, w, text) * zoom; + if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) } } fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) { @@ -545,7 +557,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 font_size = (field_text_size(painter, f, w, text) * zoom).clamp(3.0, 64.0); + let font_size = field_screen_font(painter, f, w, text, zoom); 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 { @@ -683,7 +695,7 @@ pub(crate) fn overlay( 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); + let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom); // 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). diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index dcaa7510b5..fa828a8f9f 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -406,8 +406,8 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() { let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen"); (rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize))) }; - // From inside the first word to past the end of the text. - let (from, to) = (egui::pos2(rect.left() + 30.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); + // From inside the first word ("Ad|a" at the field's auto size, 13.9 pt) to past the end of the text. + let (from, to) = (egui::pos2(rect.left() + 38.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); h.hover_at(from); h.run_steps(1); h.drag_at(from); @@ -1363,6 +1363,54 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } +/// Auto-size fields 500 pt wide: a four-cell comb 30 pt tall, and single-line fields 100 and 400 pt tall. +fn tall_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 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (comb) /Ff 16777216 /MaxLen 4 /Rect [50 740 550 770] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [50 600 550 700] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (huge) /Rect [50 100 550 500] /P 3 0 R /F 4 >>", + ]) +} + +/// The editor shows auto-size text at the size the page appearance draws it: a comb field keeps +/// the appearance's 12 pt cap, and a tall single-line field follows its height, at 100% zoom +/// too, up to the editor's screen-font bound. +#[test] +fn the_field_editor_sizes_auto_size_text_as_the_page_does() { + 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("tall.pdf", None, tall_auto_size_form()).unwrap(); + app + }); + h.run_steps(6); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let field = |name: &str| doc.form.iter().find(|f| f.name == name).unwrap().clone(); + let painter = egui::Painter::new(h.ctx.clone(), egui::LayerId::background(), egui::Rect::EVERYTHING); + let screen = |name: &str, zoom: f32| { + let f = field(name); + pdfcraft_ui_egui::forms_ui::field_screen_font(&painter, &f, &f.widgets[0], "Hi", zoom) + }; + // 100%: 96 screen points per 72 page points. + let full = 96.0 / 72.0; + + let comb = field("comb"); + let page = pdfcraft_engine::field_comb_auto_text_size(30.0 - 6.0); + assert_eq!(page, 12.0); + assert_eq!(pdfcraft_ui_egui::forms_ui::field_text_size(&painter, &comb, &comb.widgets[0], ""), 12.0, "a comb field"); + assert_eq!(screen("comb", full), 12.0 * full); + + let tall = (100.0 - 6.0) / 1.15; + assert!((screen("tall", full) - tall as f32 * full).abs() < 0.01, "a 100 pt field at 100%: {}", screen("tall", full)); + assert_eq!(screen("huge", full), pdfcraft_ui_egui::forms_ui::MAX_SCREEN_FONT, "past the screen-font bound"); + assert_eq!(screen("huge", 0.5), ((400.0 - 6.0) / 1.15) as f32 * 0.5, "below it at 50%"); +} + /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { use egui_kittest::kittest::Queryable; From 3c6612a3f90fec47bde5f58dd61a63ba336b73b6 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 00:34:14 -0400 Subject: [PATCH 3/3] fix(forms-ui): measure auto-size text at no more than the screen-font bound An auto-size single-line field's size follows its height, up to about 12,500 pt for a 14,400 pt field. The editor measured the line's width by laying it out at that size, before `field_screen_font` clamps to `MAX_SCREEN_FONT`, and egui rasterizes every glyph it lays out: one such measurement took 1.2 s in a debug build, on every frame. The width is now measured at no more than `MAX_SCREEN_FONT` and scaled, which is exact up to the bound and linear above it. `tools::forms_ui::tests::a_line_is_never_laid_out_above_the_screen_font_bound` fails with the bound removed (laid out at 12,522 pt). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/tools/forms_ui.rs | 31 +++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 4138f4e903..e5a1b36bc8 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -528,7 +528,9 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t return pdfcraft_engine::field_comb_auto_text_size(height); } let font = |size: f64| egui::FontId::proportional(size as f32); - let line_width = |size: f64| f64::from(painter.layout_no_wrap(text.to_string(), font(size), Color32::BLACK).size().x); + let line_width = |size: f64| { + bounded_line_width(size, |at| painter.layout_no_wrap(text.to_string(), egui::FontId::proportional(at), 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(text.to_string(), font(size), Color32::BLACK, wrap).size().y); @@ -537,6 +539,14 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t size as f32 } +/// The width of a line at `size` points from `measure`, the width at a given size, called at no +/// more than [`MAX_SCREEN_FONT`] and scaled: egui rasterizes every glyph it lays out, and a tall +/// field's automatic size can be thousands of points. +fn bounded_line_width(size: f64, measure: impl Fn(f32) -> f32) -> f64 { + let at = size.min(f64::from(MAX_SCREEN_FONT)); + if at > 0.0 { f64::from(measure(at as f32)) * size / at } else { f64::from(measure(at as f32)) } +} + /// The largest font, in screen points, the on-page field editor draws with: a bound that keeps /// one rasterized glyph (font size times pixels per point) well inside egui's font atlas. pub const MAX_SCREEN_FONT: f32 = 256.0; @@ -960,3 +970,22 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: let xf = view.page_xform(page)?; Some(widget_rect(&xf, info, page, w.rect)) } + +#[cfg(test)] +mod tests { + use super::{MAX_SCREEN_FONT, bounded_line_width}; + use std::cell::Cell; + + #[test] + fn a_line_is_never_laid_out_above_the_screen_font_bound() { + let largest = Cell::new(0.0f32); + let measure = |at: f32| { + largest.set(largest.get().max(at)); + at * 3.0 + }; + assert_eq!(bounded_line_width(12_522.0, measure), 12_522.0 * 3.0, "the width scales"); + assert_eq!(largest.get(), MAX_SCREEN_FONT, "laid out at the bound"); + assert_eq!(bounded_line_width(10.0, measure), 30.0, "below it, at the size itself"); + assert_eq!(largest.get(), MAX_SCREEN_FONT); + } +}