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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76
2026-10-26 19:27:44 -04:00
co-authored by Claude Opus 5.5
parent ccb2565036
commit 0e559fca41
5 changed files with 88 additions and 16 deletions
+1 -1
View File
@@ -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;
+10 -4
View File
@@ -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)
};
+6
View File
@@ -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)
}
+21 -9
View File
@@ -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).
+50 -2
View File
@@ -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<u8> {
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;