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"