Merge #4401: M6: a turned field keeps its place and its text runs along the turn (#4296)

This commit is contained in:
bflatastic committed 2026-11-09 18:23:10 +07:00
commit 3b3721405c
11 files changed
+385 -118

No files matched your search

+2 -2
View File
@@ -607,7 +607,7 @@ pub fn tools() -> Vec<ToolDef> {
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!({
@@ -631,7 +631,7 @@ pub fn tools() -> Vec<ToolDef> {
"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\"." },
+2 -6
View File
@@ -5038,12 +5038,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));
+11 -7
View File
@@ -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");
}
+3 -2
View File
@@ -118,8 +118,9 @@ pub struct FieldProps {
/// Options tab: the mark of a check box or radio button (every widget).
pub check_style: Option<CheckStyle>,
/// 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)>,
}
+19 -39
View File
@@ -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<S
}
}
// Validated before anything is written, so a refused rotation leaves the document unchanged.
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
let mut rotated_rect: Option<Option<[f64; 4]>> = 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() }) {
@@ -366,7 +341,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
})?;
}
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
let only = apply_late_widget_props(doc, &f, props, &new_da)?;
scroll_list(doc, f.obj, list_picked)?;
redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?;
if props.calculate.is_some() {
@@ -531,13 +506,19 @@ fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec<usize
Ok(())
}
fn apply_late_widget_props(
doc: &mut Document,
f: &Field,
props: &FieldProps,
new_da: &str,
rotated_rect: Option<Option<[f64; 4]>>,
) -> Result<Option<usize>, 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<Option<usize>, 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() {
@@ -551,12 +532,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");
+21 -13
View File
@@ -3255,14 +3255,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:?}");
@@ -3285,18 +3285,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]
+245 -33
View File
@@ -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, PagePress};
use crate::theme::Tokens;
@@ -91,6 +92,147 @@ 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<egui::Galley>, 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::Galley>, 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<f32>,
password: bool,
) -> impl FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc<egui::Galley> {
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<Laid>) {
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;
// 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) = ptr.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 ptr.pressed().is_none() => 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 {
!f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature)
}
@@ -364,18 +506,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;
let view_key = view.view_key();
egui::Area::new(egui::Id::unique(("form-editor", view_key)))
@@ -390,15 +534,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_key, &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.
@@ -560,7 +711,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, wi, w, r);
paint_unbaked_widget(painter, xf, TextFrame::of(xf, info, page, w, r), f, wi, w, r);
} else if fillable(f) && hovered == Some((fi, wi)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
@@ -583,7 +734,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);
@@ -619,7 +773,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, wi: usize, w: &FormWidget, r: Rect) {
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, frame: TextFrame, f: &FormField, wi: usize, w: &FormWidget, r: Rect) {
match f.kind {
FormFieldKind::Text | FormFieldKind::Combo => {
let text = f.value.first().map(String::as_str).unwrap_or("");
@@ -631,16 +785,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 => {
@@ -707,7 +863,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.
/// 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 +872,9 @@ fn settle_text_editor(
primary_down: bool,
laid_out: Option<(std::sync::Arc<egui::Galley>, Pos2)>,
fitted: Option<(&form_appearance::TextBox, String)>,
) {
frame: TextFrame,
) -> bool {
let mut pressed = false;
if let Some((tb, before)) = fitted
&& fx.text != before
{
@@ -734,12 +893,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(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 {
ui.ctx().set_dragged_id(r.id);
}
pressed = true;
}
if fx.request_focus {
r.request_focus();
@@ -752,6 +912,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
@@ -896,33 +1057,41 @@ 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 = 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
// 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.pane.key, &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));
}
let mut laid_out = None;
let r = ui
.add_sized(rect.size(), |ui: &mut egui::Ui| {
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));
if turned {
// Laid out at the upright box's width, not the field's on screen.
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 = 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)
@@ -959,7 +1128,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.view_key())))
.order(egui::Order::Foreground)
@@ -1087,7 +1260,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]
@@ -1102,4 +1276,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);
}
}
@@ -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
+67
View File
@@ -215,6 +215,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();
@@ -1665,6 +1693,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() {
+11 -7
View File
@@ -450,17 +450,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);
+2 -2
View File
@@ -4698,8 +4698,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"