diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index bb0e426c9b..239ee748bf 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -311,7 +311,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // The frame is worked out in line space, where the lines are; vertical type turns it + // into text space with the lines. + let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds); // Across, the frame spans the lines as they are aligned: from their starts to their ends // without the tracking after their last characters (`LineInfo::trailing`), not to the // ink, which tracking can pull past an advance. Area type aligns them alike. @@ -332,7 +335,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), _ => (0.0, slack), }; - let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); + let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0))); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); } @@ -368,6 +371,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result { // Point type is in no thread: its first paragraph starts in it. t.first_para_continues = false; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + // The lines are in line space; the frame's bounds are taken there too. + let fb = lay.line_xf.inverse().transform_rect_bbox(fb); let plain = t.plain_text(); let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); // Soft wraps → explicit breaks (replace the space before the wrap when there is one). @@ -414,7 +419,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result { // converting back to area type does not turn it on again. t.dynamic_text = false; t.kind = TextKind::Point; - t.xf *= Affine::translate((ox, baseline)); + // The origin is in the area type's line space; point type's line space puts its own + // origin there (vertical point type turns its lines about a different point). + let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY }; + t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse(); refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9eea9cf456..243d6d0081 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2523,6 +2523,68 @@ fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { } } +/// Horizontal and vertical type keep every line where it is when converted from point to area +/// type and back, and from area to point type and back, however the lines are aligned. Vertical +/// lines run down their columns, and each next column sits to the left of the one before it. +#[test] +fn converting_vertical_type_between_point_and_area_keeps_it_in_place() { + for vertical in [false, true] { + for j in ["left", "center", "right"] { + for text in ["Wave", "Wave\nWaves"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 300, "y": 40, "text": text, "size": 12, "vertical": vertical})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), text.lines().count()); + if vertical && before.len() > 1 { + assert!(before[1].x < before[0].x, "{j}: the second column is to the left: {before:?}"); + } + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + let area = line_starts_doc(&t); + assert_eq!(area.len(), before.len(), "vertical {vertical}, {j}, {text:?}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + let point = line_starts_doc(&t); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type to point type and back. + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 300, "y": 40, "text": "Wave\nWaves", "size": 12, "vertical": vertical, "area": {"width": 120, "height": 120}}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, point: {before:?} → {point:?}"); + } + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, area again: {before:?} → {area:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session();