mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
1 parent
f137a289ba
commit
25d07f69b5
11 files changed
+282
-103
No files matched your search
@@ -466,7 +466,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!({
|
||||
@@ -490,7 +490,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\"." },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }) {
|
||||
@@ -363,7 +338,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() {
|
||||
@@ -528,13 +503,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() {
|
||||
@@ -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");
|
||||
|
||||
+21
-13
@@ -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]
|
||||
|
||||
@@ -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<egui::Galley>, 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<egui::Galley>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user