From 25d07f69b545e4e2b7519e83912b4ae2364ff6e8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:00:02 +0800 Subject: [PATCH 1/3] fix(forms): a turned field keeps its place and its text runs along the turn (#4296) Setting a field's rotation (Properties > Position, form_set_props) swapped the widget's width and height around its center, so a tall field placed along a page edge jumped into a short, wide one, and typing in it showed the text upright. ISO 32000-2 Table 192 /MK /R turns the widget annotation relative to the page; its /Rect stays the area it occupies in default user space. So a rotation now changes only /MK /R: the rectangle stays, and the regenerated appearance lays the text out along the turned direction (the box /BBox is as wide as the field is tall, and /Matrix turns it onto /Rect). On screen, the in-place editor and the just-committed preview paint the text turned by /MK /R less the page's /Rotate and the view's rotation, as the page will show it. The editor still takes the keys and pointer over the field, and a press or drag places the caret in the turned text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 8 +- crates/engine/tests/it/form_rotation.rs | 18 +- crates/forms/src/author.rs | 5 +- crates/forms/src/author/edit.rs | 58 ++--- crates/forms/src/tests.rs | 34 +-- crates/ui-egui/src/tools/forms_ui.rs | 199 ++++++++++++++++-- .../ui-egui/src/tools/prepare/properties.rs | 9 +- crates/ui-egui/tests/it/forms.rs | 28 +++ crates/ui-egui/tests/it/prepare.rs | 18 +- parity/acrobat-features.toml | 4 +- 11 files changed, 282 insertions(+), 103 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..bb83c23b30 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -466,7 +466,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise; the field keeps its rectangle and its content turns inside it. Undoable.", ) .with(schema( json!({ @@ -490,7 +490,7 @@ pub fn tools() -> Vec { "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "font_size": { "type": "number", "minimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, - "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, + "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..c0a7a01eee 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4226,12 +4226,8 @@ fn rotating_a_field_through_tools() { ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 })); let (rot, turned) = rect_of(&mut a); assert_eq!(rot, 90); - let (bw, bh) = (before[2] - before[0], before[3] - before[1]); - let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]); - assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}"); - let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); - let (bc, tc) = (center(&before), center(&turned)); - assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}"); + // The field stays where it is; only its content turns (#4296). + assert!(turned.iter().zip(&before).all(|(t, b)| (t - b).abs() < 0.2), "the field moved: {before:?} -> {turned:?}"); assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties"); let (rot, back) = rect_of(&mut a); assert_eq!((rot, back), (0, before)); diff --git a/crates/engine/tests/it/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs index 67d560bb4b..8d7574ebec 100644 --- a/crates/engine/tests/it/form_rotation.rs +++ b/crates/engine/tests/it/form_rotation.rs @@ -23,7 +23,8 @@ fn one_page() -> Document { doc } -/// Width and height of the dark pixels on page 1. +/// Width and height of the dark pixels on page 1: the black text, not the field's grey (0.55) +/// border, which would give the field's own outline. fn ink_span(doc: &Document) -> (u32, u32) { let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); @@ -34,7 +35,7 @@ fn ink_span(doc: &Document) -> (u32, u32) { for y in 0..p.height { for x in 0..p.width { let i = ((y * p.width + x) * 4) as usize; - if p.rgba[i] < 200 { + if p.rgba[i] < 100 { ink = true; (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); } @@ -44,15 +45,18 @@ fn ink_span(doc: &Document) -> (u32, u32) { (x1 - x0, y1 - y0) } +/// A tall, narrow field turned 90 degrees stays where it is and its text runs up its long side +/// (#4296), rather than the field turning into a short, wide one that cuts the text off. #[test] fn a_rotated_field_draws_along_the_vertical_edge() { let mut doc = one_page(); - add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap(); - set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap(); - set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); + add_field(&mut doc, 0, [10.0, 40.0, 30.0, 120.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("MMMMMM".into())).unwrap(); + set_props(&mut doc, "tall", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); let (wide, tall) = ink_span(&doc); assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}"); - set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); + set_props(&mut doc, "tall", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); let (wide, tall) = ink_span(&doc); - assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}"); + // Six 12 pt M's are about 60 pt, 120 px at scale 2; the field is 20 pt (40 px) wide. + assert!(tall >= 100 && wide < 40, "the text does not run up the field: {wide}x{tall} px"); } diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index de597187d4..4f5f21d4e6 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -118,8 +118,9 @@ pub struct FieldProps { /// Options tab: the mark of a check box or radio button (every widget). pub check_style: Option, /// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise. - /// Changing between horizontal and vertical swaps that widget's width and height around its - /// center, unless [`Self::rect`] is also set. + /// The widget keeps its rectangle: `/R` turns the appearance inside it (ISO 32000-2 Table 192 + /// turns the annotation relative to the page, and its `/Rect` stays in default user space), so + /// a tall field turned 90 degrees holds text running up its long side. pub rotation: Option<(usize, i64)>, } diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2d05600ae2..91722df508 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -3,18 +3,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_wi use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) -} - /// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was /// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` /// are the old `/I` positions carried into the new list, which tell apart options sharing an @@ -139,21 +127,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } + if let Some(rotation) = props.rotation { + check_rotation(&f, name, rotation)?; } // A locked field only takes unlocking (Acrobat greys its properties out). if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { @@ -363,7 +338,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result>, -) -> Result, FormError> { +/// Whether `set_props` can turn widget `wi` of `f` to `rot`: 0, 90, 180 or 270 degrees, of a +/// widget the field has. +fn check_rotation(f: &Field, name: &str, (wi, rot): (usize, i64)) -> Result<(), FormError> { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + if f.widgets.get(wi).is_none() { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + } + Ok(()) +} + +fn apply_late_widget_props(doc: &mut Document, f: &Field, props: &FieldProps, new_da: &str) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -548,12 +529,11 @@ fn apply_late_widget_props( } } } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) + // Only `/MK /R` changes: the widget stays where it is on the page, and the redraw below lays + // its content out along the turned direction inside the same rectangle. + if let Some((wi, rot)) = props.rotation && let Some(w) = f.widgets.get(wi) { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); if rot == 0 { mk.remove(b"R"); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..be9fa9785e 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2674,14 +2674,14 @@ fn rotated_fields_draw_in_their_quadrant() { let turned = field(&fields(&doc), "name").widgets[0].clone(); assert_eq!(turned.rotation, 90); let r = turned.rect; - // [50 700 250 720] around its center becomes 20 wide and 200 tall. - assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}"); + // The field stays where it was: only its content turns. + assert!(r.iter().zip(before).all(|(a, b)| close(*a, b)), "{r:?} moved from {before:?}"); let widget = doc.get(turned.obj); let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap(); assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90)); let (_, matrix) = appearance_box(&doc, &turned); assert!(matrix.is_some()); - // 270 stays on the vertical axis, so the rectangle is not swapped again. + // 270 keeps the rectangle too. set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap(); let r270 = field(&fields(&doc), "name").widgets[0].rect; assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}"); @@ -2704,18 +2704,26 @@ fn rotated_fields_draw_in_their_quadrant() { assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}"); } +/// #4296: a tall field turned a quarter keeps its place, and its text is laid out along the long +/// side (the appearance box is as wide as the field is tall) and turned onto the field. #[test] -fn a_rotation_refused_as_too_small_changes_nothing_else() { +fn a_tall_field_turned_a_quarter_keeps_its_place_and_runs_its_text_up_it() { let mut doc = one_page(); - add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap(); - // 200 x 3: turned a quarter it would be 3 wide, too small to use. - let obj = field(&fields(&doc), "thin").widgets[0].obj; - doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap(); - let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() }; - assert!(set_props(&mut doc, "thin", &props).is_err()); - let f = field(&fields(&doc), "thin").clone(); - assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused"); - assert_eq!(f.widgets[0].rotation, 0); + let rect = [50.0, 600.0, 70.0, 760.0]; + add_field(&mut doc, 0, rect, &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("Ada".into())).unwrap(); + let props = FieldProps { tooltip: Some("Name".into()), rotation: Some((0, 90)), ..FieldProps::default() }; + set_props(&mut doc, "tall", &props).unwrap(); + let f = field(&fields(&doc), "tall").clone(); + let w = &f.widgets[0]; + assert_eq!((w.rotation, f.tooltip.as_deref()), (90, Some("Name"))); + assert!(w.rect.iter().zip(rect).all(|(a, b)| close(*a, b)), "the field moved: {:?} -> {:?}", rect, w.rect); + let (bbox, matrix) = appearance_box(&doc, w); + assert!(close(bbox[2], 160.0) && close(bbox[3], 20.0), "the text is laid out across the field, not up it: {bbox:?}"); + let m = matrix.unwrap(); + // (x, y) -> (20 - y, x): the box's 160 pt run goes up the field. + assert!(close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 20.0), "{m:?}"); + assert!(ap(&doc, w).contains("(Ada) Tj")); } #[test] diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 7232b1169b..d575435482 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -91,6 +91,52 @@ fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } +/// Where a field's text sits on screen. It is laid out upright in `upright`, the field's screen +/// rectangle with its sides swapped when the text runs up or down the screen, and turned onto +/// the field about its centre. The field's `/MK /R` turns its content counterclockwise on the +/// page (ISO 32000-2 Table 192), as its page appearance draws it; the page's `/Rotate` and the +/// view's rotation turn the page clockwise. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TextFrame { + pub upright: Rect, + /// Clockwise on screen: 0, 90, 180 or 270 degrees. + pub turn: i64, +} + +impl TextFrame { + pub(crate) fn new(field: Rect, mk_r: i64, page_rotate: u16, view_rotation: u16) -> Self { + // Each term is reduced first, so the sum cannot overflow. + let turn = (i64::from(page_rotate % 360) + i64::from(view_rotation % 360) - mk_r.rem_euclid(360)).rem_euclid(360) / 90 * 90; + let upright = if turn % 180 == 90 { Rect::from_center_size(field.center(), vec2(field.height(), field.width())) } else { field }; + Self { upright, turn } + } + + fn of(xf: &PageXform, info: &DocInfo, page: usize, w: &FormWidget, field: Rect) -> Self { + Self::new(field, w.rotation, info.pages.get(page).map_or(0, |p| p.rotation), xf.rot) + } + + fn angle(self) -> f32 { + (self.turn as f32).to_radians() + } + + /// A point of the upright box, on screen. + pub(crate) fn on_screen(self, p: egui::Pos2) -> egui::Pos2 { + let c = self.upright.center(); + c + egui::emath::Rot2::from_angle(self.angle()) * (p - c) + } + + /// A screen point, in the upright box. + pub(crate) fn in_box(self, p: egui::Pos2) -> egui::Pos2 { + let c = self.upright.center(); + c + egui::emath::Rot2::from_angle(-self.angle()) * (p - c) + } + + /// Paint `galley` with its top-left corner at `pos` in the upright box. + fn paint_galley(self, painter: &egui::Painter, pos: egui::Pos2, galley: std::sync::Arc, color: Color32) { + painter.add(egui::epaint::TextShape::new(self.on_screen(pos), galley, color).with_angle(self.angle())); + } +} + fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } @@ -507,7 +553,7 @@ pub(crate) fn paint_page( painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside); } if view.forms.unbaked.contains(&(f.name.clone(), page)) { - paint_unbaked_widget(painter, xf, f, w, r); + paint_unbaked_widget(painter, xf, TextFrame::of(xf, info, page, w, r), f, w, r); } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } @@ -530,7 +576,10 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t 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); + let (across, up) = ((w.rect[2] - w.rect[0]).abs(), (w.rect[3] - w.rect[1]).abs()); + // A field turned a quarter lays its text out along its height, as its appearance does. + let (across, up) = if matches!(w.rotation, 90 | 270) { (up, across) } else { (across, up) }; + let (width, height) = (across - 2.0 * pad, up - 2.0 * pad); // 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); @@ -566,7 +615,7 @@ pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, 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) { +fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, frame: TextFrame, f: &FormField, w: &FormWidget, r: Rect) { match f.kind { FormFieldKind::Text | FormFieldKind::Combo => { let text = f.value.first().map(String::as_str).unwrap_or(""); @@ -578,16 +627,18 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, 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); + // Laid out upright in the frame's box, then turned onto the field (#4296). + let b = frame.upright; let (align, pos) = match f.quadding { - 1 => (egui::Align2::CENTER_TOP, egui::pos2(r.center().x, r.min.y + 1.0)), - 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)), + 1 => (egui::Align2::CENTER_TOP, egui::pos2(b.center().x, b.min.y + 1.0)), + 2 => (egui::Align2::RIGHT_TOP, egui::pos2(b.max.x - 3.0, b.min.y + 1.0)), + _ => (egui::Align2::LEFT_TOP, egui::pos2(b.min.x + 3.0, b.min.y + 1.0)), }; // 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 wrap = if f.has(field_flags::MULTILINE) { (b.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); + frame.paint_galley(&p, align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); } } FormFieldKind::List => { @@ -654,7 +705,9 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ } /// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the -/// caret where the press that opened the editor landed, and takes focus when asked. +/// caret where the press that opened the editor landed, and takes focus when asked. `in_galley` +/// turns a screen point into a position in the laid-out text, which a turned field's editor +/// lays out upright (#4296). Returns whether it placed the caret for such a press. fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, @@ -662,7 +715,9 @@ fn settle_text_editor( primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, -) { + in_galley: impl Fn(&egui::Galley, Pos2, Pos2) -> egui::Vec2, +) -> bool { + let mut pressed = false; if let Some((tb, before)) = fitted && fx.text != before { @@ -681,12 +736,13 @@ fn settle_text_editor( && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + let at = galley.cursor_from_pos(in_galley(&galley, pos, press)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); if primary_down { ui.ctx().set_dragged_id(r.id); } + pressed = true; } if fx.request_focus { r.request_focus(); @@ -699,6 +755,7 @@ fn settle_text_editor( st.store(ui.ctx(), r.id); } } + pressed } /// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab @@ -814,33 +871,100 @@ pub(crate) fn overlay( 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()); + // A turned field (/MK /R, or a turned page or view) is typed into where it is: + // the editor takes the keys and the pointer over the field, but draws nothing, + // and its text is painted turned below, as the page will show it (#4296). + let frame = TextFrame::of(&xf, info, page, w, rect); + let turned = frame.turn != 0; + let upright = frame.upright; + let password = f.has(field_flags::PASSWORD); + // Wraps (inside the 3 px side margins) at the width the size was fitted to: + // the box less 3 pt each side, scaled. + let wrap = (upright.width() - 6.0 * zoom).max(1.0); + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _: f32| { + let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; + let font = egui::FontId::proportional(font); + let mut job = if multiline { + egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap) + } else { + egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK) + }; + job.keep_trailing_whitespace = true; + ui.fonts_mut(|f| f.layout_job(job)) + }; 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: - // the box less 3 pt each side, scaled. - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .desired_width((upright.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .password(f.has(field_flags::PASSWORD)) + .password(password) .id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } if multiline { - te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); + te = te.desired_rows(((upright.height() / (font * 1.3)).floor() as usize).max(1)); + } + if turned { + // Laid out at the upright box's width, not the field's on screen. + te = te.layouter(&mut layouter); } let mut laid_out = None; let r = ui .add_sized(rect.size(), |ui: &mut egui::Ui| { + if turned { + ui.set_opacity(0.0); + } let out = te.show(ui); laid_out = Some((out.galley, out.galley_pos)); out.response.response }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - settle_text_editor(ui, fx, &r, primary_down, laid_out, text_box.zip(before)); + // Where the upright text starts: inside the editor's margins. + let origin = upright.min + vec2(3.0, 1.0); + // A screen point as a position in the laid-out text. + let in_galley = |galley: &egui::Galley, pos: Pos2, p: Pos2| { + if turned { frame.in_box(p) - origin + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } + }; + let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), in_galley); + if !caret_placed + && turned + && r.is_pointer_button_down_on() + && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + && let Some((galley, pos)) = &laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The editor placed the caret as if its text were upright: place it under + // the pointer in the turned text instead, a press at the point and a drag + // from where the press left it. + let at = galley.cursor_from_pos(in_galley(galley, *pos, p)); + let range = match st.cursor.char_range() { + Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), + _ => egui::text::CCursorRange::one(at), + }; + st.cursor.set_char_range(Some(range)); + st.store(ui.ctx(), r.id); + } + if turned && let Some((galley, _)) = &laid_out { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); + let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); + let mut shown = galley.clone(); + if let Some(range) = &range { + egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); + } + let start = origin - vec2(galley.rect.left(), 0.0); + frame.paint_galley(&painter, start, shown, Color32::BLACK); + if let Some(range) = range { + let mut caret = galley.pos_from_cursor(range.primary); + caret.max.y = caret.max.y.max(caret.min.y + font); + let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); + painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); + } + } let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -1005,7 +1129,8 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: #[cfg(test)] mod tests { - use super::{MAX_SCREEN_FONT, bounded_line_width}; + use super::{MAX_SCREEN_FONT, TextFrame, bounded_line_width}; + use egui::{Rect, pos2}; use std::cell::Cell; #[test] @@ -1020,4 +1145,42 @@ mod tests { assert_eq!(bounded_line_width(10.0, measure), 30.0, "below it, at the size itself"); assert_eq!(largest.get(), MAX_SCREEN_FONT); } + + fn near(a: egui::Pos2, b: egui::Pos2) -> bool { + (a - b).length() < 1e-3 + } + + /// #4296: a tall field turned 90 degrees counterclockwise lays its text out in a box as wide + /// as the field is tall, and that box's top-left corner lands on the field's bottom-left, so + /// the text runs up the screen. + #[test] + fn a_quarter_turned_field_runs_its_text_up_the_screen() { + let field = Rect::from_min_max(pos2(100.0, 100.0), pos2(120.0, 260.0)); + let frame = TextFrame::new(field, 90, 0, 0); + assert_eq!(frame.turn, 270); + assert_eq!(frame.upright.size(), egui::vec2(160.0, 20.0)); + assert!(near(frame.on_screen(frame.upright.left_top()), field.left_bottom()), "{:?}", frame.on_screen(frame.upright.left_top())); + assert!(near(frame.on_screen(frame.upright.right_top()), field.left_top())); + for p in [pos2(105.0, 110.0), pos2(119.0, 250.0)] { + assert!(near(frame.on_screen(frame.in_box(p)), p)); + } + // 270 runs down the screen, from the top-right corner. + let down = TextFrame::new(field, 270, 0, 0); + assert_eq!(down.turn, 90); + assert!(near(down.on_screen(down.upright.left_top()), field.right_top())); + } + + /// The page's /Rotate and the view's rotation turn the page clockwise, against /MK /R: a field + /// turned 90 on a page shown a quarter clockwise reads upright, in its own rectangle. + #[test] + fn page_and_view_rotation_turn_against_the_field() { + let field = Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 20.0)); + assert_eq!(TextFrame::new(field, 90, 90, 0), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 90, 0, 90), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 0, 0, 0), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 180, 270, 270).turn, 0); + assert_eq!(TextFrame::new(field, 0, 180, 0).turn, 180); + // Out-of-range input stays a quarter turn. + assert_eq!(TextFrame::new(field, i64::MIN, u16::MAX, u16::MAX).turn % 90, 0); + } } diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index fd74420707..120452adf8 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -161,17 +161,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.add_space(8.0); ui.horizontal(|ui| { let l = ui.label(tl!("Rotation:")); - let before = d.rotation; + // The field keeps its place and size: the rotation turns its content (#4296). craft_ui::ComboBox::from_id_salt("field-rotation") .selected_text(format!("{}°", d.rotation)) .show_ui(ui, |ui| { for r in [0, 90, 180, 270] { - if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) { - // Keep the same center. set_props skips its own swap when the rect changed too. - let [x, y, w, h] = d.position; - let (cx, cy) = (x + w / 2.0, y + h / 2.0); - d.position = [cx - h / 2.0, cy - w / 2.0, h, w]; - } + ui.selectable_value(&mut d.rotation, r, format!("{r}°")); } }) .response diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index fa828a8f9f..8722d5fdc8 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -168,6 +168,34 @@ fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); } +/// #4296: a field turned a quarter (`/MK /R` 90) is typed into where it is, with its text running +/// up the field as the page draws it: a click near the field's top puts the caret at the end of +/// the text, where the turned text ends, not at its start. +#[test] +fn a_turned_field_places_the_caret_along_its_turned_text() { + let mut h = harness(); + // Tall and narrow, with room up it for the text. + let props = pdfcraft_engine::FieldProps { rect: Some((0, [240.0, 20.0, 260.0, 170.0])), font_size: Some(10.0), ..Default::default() }; + set_city(&mut h, props); + set_city(&mut h, pdfcraft_engine::FieldProps { rotation: Some((0, 90)), ..Default::default() }); + click_field(&mut h, "city", 0); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(2); + let field = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "city").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0)); + h.event(egui::Event::Text("X".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "city"), ["ParisX"]); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 82e33b29de..5d7958295c 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -335,17 +335,21 @@ fn position_tab_rotates_a_field() { assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit"); h.get_by_label("Position").click(); h.run_steps(2); - h.get_by_label("Rotation:"); - h.state_mut().field_props.as_mut().unwrap().rotation = 90; - assert!(h.state().field_props.as_ref().unwrap().props().is_some()); + // Picked from the list, as a person does. + h.get_by_label("Rotation:").click(); + h.run_steps(2); + h.get_by_label("90°").click(); + h.run_steps(2); + let draft = h.state().field_props.as_ref().unwrap().clone(); + assert_eq!(draft.rotation, 90); + let props = draft.props().expect("a new rotation is an edit"); + assert!(props.rect.is_none(), "picking a rotation moved the field: {:?}", props.rect); h.get_by_label("OK").click(); h.run_steps(3); let turned = widget(&h); assert_eq!(turned.rotation, 90); - let [x0, y0, x1, y1] = before.rect; - let (cx, cy, w, ht) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, x1 - x0, y1 - y0); - let expect = [cx - ht / 2.0, cy - w / 2.0, cx + ht / 2.0, cy + w / 2.0]; - assert!(turned.rect.iter().zip(expect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {expect:?}", turned.rect); + // The field stays where it was placed; only its content turns (#4296). + assert!(turned.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} moved from {:?}", turned.rect, before.rect); h.state_mut().execute("edit.undo"); h.run_steps(3); let back = widget(&h); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..eba3081f8a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4571,8 +4571,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/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" +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/forms/src/tests.rs::a_tall_field_turned_a_quarter_keeps_its_place_and_runs_its_text_up_it", "crates/ui-egui/tests/it/forms.rs::a_turned_field_places_the_caret_along_its_turned_text", "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: the field keeps its rectangle and its text runs along the turned direction, also while it is typed into; double-click word selection in a turned field is not mapped); 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 83d0654b0e1247f5c89cac3cf85c66450f33f12b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:28:31 +0800 Subject: [PATCH 2/3] fix(forms): a turned editable combo box types along its turn (#4296) An editable combo box (ISO 32000-2 Table 233, Edit) turned by /MK /R (Table 192) sized its font for the turn but still drew a horizontal editor, so its text, selection and caret ran across the field while the committed text ran along it. The combo editor now takes the field's TextFrame and shares the turned text field's path: an upright layout at the turned box's width, the pointer mapped back into the upright text, and the text, selection and caret painted turned. The shared pieces are pulled out of the text field editor (turned_layouter, show_field_editor, turned_caret_and_paint) so the two editors cannot drift apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/ui-egui/src/tools/forms_ui.rs | 195 ++++++++++++++++----------- crates/ui-egui/tests/it/forms.rs | 39 ++++++ 2 files changed, 158 insertions(+), 76 deletions(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index d575435482..c2359de6d1 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -135,6 +135,96 @@ impl TextFrame { fn paint_galley(self, painter: &egui::Painter, pos: egui::Pos2, galley: std::sync::Arc, color: Color32) { painter.add(egui::epaint::TextShape::new(self.on_screen(pos), galley, color).with_angle(self.angle())); } + + /// Where a field editor's upright text starts: inside its margins. + fn text_origin(self) -> egui::Pos2 { + self.upright.min + vec2(3.0, 1.0) + } + + /// A screen point `p` as a position in `galley`, which the editor drew at `pos`; a turned + /// field's text is laid out upright in the frame's box. + fn in_galley(self, galley: &egui::Galley, pos: egui::Pos2, p: egui::Pos2) -> egui::Vec2 { + if self.turn != 0 { self.in_box(p) - self.text_origin() + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } + } +} + +/// A field editor's laid-out text and where the editor drew it. +type Laid = (std::sync::Arc, egui::Pos2); + +/// How a turned field's editor lays its text out: at the upright box's width (`wrap`, for a +/// multi-line field), not the field's on screen. +fn turned_layouter( + font: f32, + wrap: Option, + password: bool, +) -> impl FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc { + move |ui, text, _| { + let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; + let font = egui::FontId::proportional(font); + let mut job = match wrap { + Some(wrap) => egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap), + None => egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK), + }; + job.keep_trailing_whitespace = true; + ui.fonts_mut(|f| f.layout_job(job)) + } +} + +/// Show a field's editor over `rect`. A turned field's editor takes the keys and the pointer +/// over the field but draws nothing: [`turned_caret_and_paint`] paints its text turned (#4296). +fn show_field_editor(ui: &mut egui::Ui, te: egui::TextEdit<'_>, rect: Rect, turned: bool) -> (egui::Response, Option) { + let mut laid_out = None; + let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { + if turned { + ui.set_opacity(0.0); + } + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }); + (r, laid_out) +} + +/// Unless the caret was just placed under the press that opened the editor (`caret_placed`, by +/// [`settle_text_editor`]), place it under the pointer in a turned field's text, and paint a +/// turned field's text, selection and caret as the page will show them. +fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: TextFrame, laid_out: Option<&Laid>, caret_placed: bool, font: f32) { + let turned = frame.turn != 0; + if !caret_placed + && turned + && r.is_pointer_button_down_on() + && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The editor placed the caret as if its text were upright: place it under the pointer + // in the turned text instead, a press at the point and a drag from where the press + // left it. + let at = galley.cursor_from_pos(frame.in_galley(galley, *pos, p)); + let range = match st.cursor.char_range() { + Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), + _ => egui::text::CCursorRange::one(at), + }; + st.cursor.set_char_range(Some(range)); + st.store(ui.ctx(), r.id); + } + if turned && let Some((galley, _)) = laid_out { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); + let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); + let mut shown = galley.clone(); + if let Some(range) = &range { + egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); + } + let start = frame.text_origin() - vec2(galley.rect.left(), 0.0); + frame.paint_galley(&painter, start, shown, Color32::BLACK); + if let Some(range) = range { + let mut caret = galley.pos_from_cursor(range.primary); + caret.max.y = caret.max.y.max(caret.min.y + font); + let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); + painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); + } + } } fn fillable(f: &FormField) -> bool { @@ -363,18 +453,20 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { } /// The text box of an editable combo box, over the field. What is typed becomes the value when -/// Enter, Tab or a click away commits it; a pick from the list still sets an option. +/// Enter, Tab or a click away commits it; a pick from the list still sets an option. A turned +/// field is typed into as a turned text field is: its text runs along the turn (#4296). struct ComboEditor<'a> { form: &'a [FormField], field: &'a FormField, widget: &'a FormWidget, rect: Rect, + frame: TextFrame, zoom: f32, index: usize, } fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>) { - let ComboEditor { form, field: f, widget: w, rect, zoom, index: widget } = editor; + let ComboEditor { form, field: f, widget: w, rect, frame, zoom, index: widget } = editor; let mut enter = false; egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) @@ -388,15 +480,22 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_> 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) - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + let turned = frame.turn != 0; + let mut layouter = turned_layouter(font, None, false); + let mut te = egui::TextEdit::singleline(&mut fx.text) + .desired_width((frame.upright.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .id(egui::Id::unique(("form-field", view.id.0, &f.name, widget))); + if turned { + te = te.layouter(&mut layouter); + } + let (r, laid_out) = show_field_editor(ui, te, rect, turned); // The form field's own logical name; not a visual/preordered label. - let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); + let r = r.accessible_name(f.name.clone()); + turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), false, font); if std::mem::take(&mut fx.request_focus) { r.request_focus(); // Entered with Tab: typing replaces the value. @@ -705,9 +804,8 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ } /// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the -/// caret where the press that opened the editor landed, and takes focus when asked. `in_galley` -/// turns a screen point into a position in the laid-out text, which a turned field's editor -/// lays out upright (#4296). Returns whether it placed the caret for such a press. +/// caret where the press that opened the editor landed (in the upright text of a turned field, +/// through `frame`, #4296), and takes focus when asked. Returns whether it placed the caret. fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, @@ -715,7 +813,7 @@ fn settle_text_editor( primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, - in_galley: impl Fn(&egui::Galley, Pos2, Pos2) -> egui::Vec2, + frame: TextFrame, ) -> bool { let mut pressed = false; if let Some((tb, before)) = fitted @@ -736,7 +834,7 @@ fn settle_text_editor( && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(in_galley(&galley, pos, press)); + let at = galley.cursor_from_pos(frame.in_galley(&galley, pos, press)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); if primary_down { @@ -881,17 +979,7 @@ pub(crate) fn overlay( // Wraps (inside the 3 px side margins) at the width the size was fitted to: // the box less 3 pt each side, scaled. let wrap = (upright.width() - 6.0 * zoom).max(1.0); - let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _: f32| { - let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; - let font = egui::FontId::proportional(font); - let mut job = if multiline { - egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap) - } else { - egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK) - }; - job.keep_trailing_whitespace = true; - ui.fonts_mut(|f| f.layout_job(job)) - }; + let mut layouter = turned_layouter(font, multiline.then_some(wrap), password); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te .desired_width((upright.width() - 6.0 * zoom + 6.0).max(1.0)) @@ -911,60 +999,11 @@ pub(crate) fn overlay( // Laid out at the upright box's width, not the field's on screen. te = te.layouter(&mut layouter); } - let mut laid_out = None; - let r = ui - .add_sized(rect.size(), |ui: &mut egui::Ui| { - if turned { - ui.set_opacity(0.0); - } - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }) - // The form field's own logical name; not a visual/preordered label. - .accessible_name(f.name.clone()); - // Where the upright text starts: inside the editor's margins. - let origin = upright.min + vec2(3.0, 1.0); - // A screen point as a position in the laid-out text. - let in_galley = |galley: &egui::Galley, pos: Pos2, p: Pos2| { - if turned { frame.in_box(p) - origin + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } - }; - let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), in_galley); - if !caret_placed - && turned - && r.is_pointer_button_down_on() - && let Some(p) = ui.input(|i| i.pointer.interact_pos()) - && let Some((galley, pos)) = &laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The editor placed the caret as if its text were upright: place it under - // the pointer in the turned text instead, a press at the point and a drag - // from where the press left it. - let at = galley.cursor_from_pos(in_galley(galley, *pos, p)); - let range = match st.cursor.char_range() { - Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), - _ => egui::text::CCursorRange::one(at), - }; - st.cursor.set_char_range(Some(range)); - st.store(ui.ctx(), r.id); - } - if turned && let Some((galley, _)) = &laid_out { - let painter = ui.painter_at(rect); - painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); - let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); - let mut shown = galley.clone(); - if let Some(range) = &range { - egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); - } - let start = origin - vec2(galley.rect.left(), 0.0); - frame.paint_galley(&painter, start, shown, Color32::BLACK); - if let Some(range) = range { - let mut caret = galley.pos_from_cursor(range.primary); - caret.max.y = caret.max.y.max(caret.min.y + font); - let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); - painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); - } - } + let (r, laid_out) = show_field_editor(ui, te, rect, turned); + // The form field's own logical name; not a visual/preordered label. + let r = r.accessible_name(f.name.clone()); + let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), frame); + turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), caret_placed, font); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -1001,7 +1040,11 @@ pub(crate) fn overlay( // An editable combo box has a text box over the field as well as its list (ISO // 32000-2 Table 233, Edit). if editable_combo(f) { - combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget }); + combo_editor( + ctx, + view, + ComboEditor { form, field: f, widget: w, rect, frame: TextFrame::of(&xf, info, page, w, rect), zoom, index: focus.widget }, + ); } let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) .order(egui::Order::Foreground) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 8722d5fdc8..0165aa2b1a 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1565,6 +1565,45 @@ fn an_editable_combo_box_takes_a_typed_value() { let _ = std::fs::remove_dir_all(&dir); } +/// #4296: an editable combo box turned a quarter (`/MK /R` 90, ISO 32000-2 Table 192) is typed +/// into as a turned text field is: a click near the field's top puts the caret at the end of the +/// text, where the text running up the field ends, not at its start. +#[test] +fn a_turned_editable_combo_box_places_the_caret_along_its_turned_text() { + let pdf = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 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] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [240 20 260 170] /MK << /R 90 >> /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("turned-combo.pdf", None, pdf.clone()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + click_field(&mut h, "fruit", 0); + assert_eq!(focused(&h), Some(("fruit".into(), 0))); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(2); + let field = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "fruit").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0)); + h.event(egui::Event::Text("X".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["ParisX"]); +} + /// A combo box without the Edit flag is a drop-down list only: typing changes nothing. #[test] fn a_combo_box_without_edit_takes_no_typing() { From 7dbf3809575d29125aa9cc1ae7e3962b13a08740 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:37:54 +0800 Subject: [PATCH 3/3] fix(forms): a turned field's editor reads the pointer through PagePointer (#4296) The turned editor's caret placement read egui's pointer state directly (primary_down, interact_pos, primary_pressed), which the page-pointer guard refuses in page modules. The editor is an Area over the page, so it reads a PagePointer for its own response: the press on it is its own, held until the frame of its release, a touch's lift included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/ui-egui/src/tools/forms_ui.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index c2359de6d1..864792a638 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -15,6 +15,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_pointer::PagePointer; use crate::page_router::{Hit, Owner}; use crate::theme::Tokens; @@ -190,10 +191,15 @@ fn show_field_editor(ui: &mut egui::Ui, te: egui::TextEdit<'_>, rect: Rect, turn /// turned field's text, selection and caret as the page will show them. fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: TextFrame, laid_out: Option<&Laid>, caret_placed: bool, font: f32) { let turned = frame.turn != 0; + // The pointer as the editor sees it. The editor is an Area over the page, so a press on it + // is its own and the pages' pointer does not reach it: read for the editor's response, it + // holds the pointer from a press on the editor until the frame of its release, a touch's + // lift included. A dialog over the editor takes the presses before they reach it. + let ptr = PagePointer::read(ui, r, false); if !caret_placed && turned && r.is_pointer_button_down_on() - && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + && let Some(p) = ptr.pos && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { @@ -202,7 +208,7 @@ fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: // left it. let at = galley.cursor_from_pos(frame.in_galley(galley, *pos, p)); let range = match st.cursor.char_range() { - Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), + Some(was) if ptr.pressed().is_none() => egui::text::CCursorRange::two(was.secondary, at), _ => egui::text::CCursorRange::one(at), }; st.cursor.set_char_range(Some(range));