From ddbd71e2976f7e44f7790e94a15006f604cab1d2 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:18:46 +0200 Subject: [PATCH] Shapes: flipped shapes keep their text readable (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips mirror a shape's geometry, never its text. The text was drawn with the shape's full transform, so every label on a flipped arrow came out mirrored (flipH) or mirrored upside down (flipV). As in PowerPoint, the text rect keeps its flipped place and the text is mirrored back about the rect's centre: flipH text stays upright, flipV (or both flips) turns it 180°, rotation still applies, and the same holds for shapes inside flipped groups. render::text_transform is that transform (plus vertical-text rotation). Drawing, the PDF text layer and link boxes, morph text, the editing caret and selection, and click-to-place-the-caret all use it, so a click lands on the character drawn under it. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/tests.rs | 21 +++++++++ crates/engine/src/tools.rs | 4 +- crates/render/src/lib.rs | 26 +++++++++--- crates/render/src/morph_text.rs | 11 +++-- crates/render/src/placed.rs | 9 +--- crates/render/src/tests.rs | 75 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 11 ++--- 7 files changed, 131 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d1c13d..e5d0e1f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -426,6 +426,27 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +#[test] +fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() { + // Flips don't mirror text, so a click lands on the character drawn under it. + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 100], "text": "Hello flipped world"})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + let pos = |s: &Session, x: f64, y: f64| { + let st = s.doc().unwrap(); + tools::text_pos(st, st.shape(id).unwrap(), deckcraft_geom::Point::new(x, y)) + }; + let xs = [240.0, 300.0, 360.0]; + let plain = xs.map(|x| pos(&s, x, 150.0)); + assert!(plain[0].1 < plain[1].1 && plain[1].1 < plain[2].1, "{plain:?}"); + s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(xs.map(|x| pos(&s, x, 150.0)), plain); + // flipV turns the text 180° about the box centre (300, 150). + s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap(); + s.execute("shape.flip", &json!({"axis": "vertical"})).unwrap(); + assert_eq!(xs.map(|x| pos(&s, 600.0 - x, 150.0)), plain); +} + #[test] fn inserted_hyperlinks_look_like_links() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a19e2bd..b60e20a 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1258,10 +1258,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option (usize, usize) { let x = xfrm_of(&st.doc, &st.selection, sh); - let local = x.affine().inverse() * p; let t = TextSel { shape: sh.id, ..Default::default() }; match cmd::text::layout_for(st, &t) { Some(l) => { + // The text's own transform: flips don't mirror text, vertical text is turned. + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p; let h = l.hit(local); (h.para, h.ch) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 931e7a7..04e5049 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1023,6 +1023,25 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody draw_layout(ctx, &l, tr, m); } +/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local → +/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre. +/// +/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's +/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about +/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180° +/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this. +pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { + let c = tr.center().to_vec2(); + let mut t = m; + if m.determinant() < 0.0 && c.is_finite() { + t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c); + } + if rotation != 0.0 && rotation.is_finite() && c.is_finite() { + t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c); + } + t +} + /// Draw an already laid-out text block. pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { draw_layout_with(ctx, l, tr, m, &[]); @@ -1031,12 +1050,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: /// Draw a laid-out text block with per-paragraph animation states. pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); - let m = if l.rotation != 0.0 { - let c = tr.center().to_vec2(); - m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c) - } else { - m - }; + let m = text_transform(m, tr, l.rotation); let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { let ps = para(d.para); if !ps.visible || ps.opacity <= 0.001 { diff --git a/crates/render/src/morph_text.rs b/crates/render/src/morph_text.rs index 995435f..40798dd 100644 --- a/crates/render/src/morph_text.rs +++ b/crates/render/src/morph_text.rs @@ -9,7 +9,7 @@ use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout}; use kurbo::{Affine, Point, Vec2}; use vello_cpu::RenderContext; -use crate::{color, draw_glyph, draw_layout, shape_geometry}; +use crate::{color, draw_glyph, draw_layout, shape_geometry, text_transform}; /// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters. #[derive(Clone, Copy, Debug, PartialEq)] @@ -140,8 +140,11 @@ pub(crate) fn draw( let (ta, loose_a) = tokens(&la, m.chars); let (tb, loose_b) = tokens(&lb, m.chars); let pairs = matches(&ta, &tb); - let (ma, mb) = (view * m.from.affine(), view * m.to.affine()); - let c = mt.as_coeffs(); + // Flipped boxes keep their text readable, as when drawn (see `text_transform`). + let (tra, trb) = (shape_geometry(a, m.from.w, m.from.h).text_rect, shape_geometry(b, m.to.w, m.to.h).text_rect); + let (ma, mb) = (text_transform(view * m.from.affine(), tra, 0.0), text_transform(view * m.to.affine(), trb, 0.0)); + let (mta, mtb) = (text_transform(mt, tra, 0.0), text_transform(mt, trb, 0.0)); + let c = mta.as_coeffs(); let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]); let mut moved_a = vec![false; ta.len()]; // Shared tokens travel. @@ -195,7 +198,7 @@ pub(crate) fn draw( let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t); (a * ma, b * mb) } else { - (mt, mt) + (mta, mtb) }; let unmatched_a = ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied()); diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 5801a35..ac084df 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -12,7 +12,7 @@ use crate::{SlideFields, shape_geometry, table}; /// One shape's text as drawn on the slide. pub struct PlacedText { pub layout: TextLayout, - /// Shape-local → slide points (text rotation included). + /// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]). pub transform: Affine, pub body: TextBody, } @@ -97,12 +97,7 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: /// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks. fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); - let m = if layout.rotation != 0.0 { - let c = tr.center().to_vec2(); - m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c) - } else { - m - }; + let m = crate::text_transform(m, tr, layout.rotation); // Text hyperlinks: one box per line segment of a linked run. for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) { let Some(link) = run_link(body, run.para, run.chars.0) else { continue }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index eaac627..e8900b8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -273,6 +273,81 @@ fn morph_text_ends_match_the_slides() { assert!(ink > 50, "ink {ink}"); } +#[test] +fn text_transform_unmirrors_flips() { + // Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text. + let tr = Rect::new(10.0, 0.0, 40.0, 50.0); + let at = |x: Xfrm, p: Point| text_transform(x.affine(), tr, 0.0) * p; + let near = |a: Point, b: Point| (a - b).hypot() < 1e-9; + let base = Xfrm::new(0.0, 0.0, 100.0, 50.0); + // flipH: the rect moves to 60..90 and the text still runs left to right. + let h = Xfrm { flip_h: true, ..base }; + assert!(near(at(h, Point::new(10.0, 0.0)), Point::new(60.0, 0.0))); + assert!(near(at(h, Point::new(40.0, 50.0)), Point::new(90.0, 50.0))); + // flipV: the text is turned 180° in its (vertically mirrored) rect. + let v = Xfrm { flip_v: true, ..base }; + assert!(near(at(v, Point::new(10.0, 0.0)), Point::new(40.0, 50.0))); + assert!(near(at(v, Point::new(40.0, 50.0)), Point::new(10.0, 0.0))); + // Both flips: the same as a 180° turn. + let hv = Xfrm { flip_h: true, flip_v: true, ..base }; + let turned = Xfrm { rot: 180.0, ..base }; + for p in [Point::new(10.0, 0.0), Point::new(40.0, 50.0), Point::new(25.0, 10.0)] { + assert!(near(at(hv, p), at(turned, p))); + } + // Unflipped and hostile input: plain transform, nothing NaN from the helper itself. + assert_eq!(text_transform(base.affine(), tr, 0.0), base.affine()); + let _ = text_transform(h.affine(), Rect::new(f64::NAN, 0.0, f64::INFINITY, 1.0), f64::NAN); +} + +/// Slide with text box `x` holding left-aligned, asymmetric text. +fn flipped_text(x: Xfrm, group: Option) -> Image { + let mut s = text_shape(1, "Fly 42 RJ", 0.0, 0.0); + s.xfrm = Some(x); + let s = match group { + Some(g) => Shape { + id: ShapeId(2), + xfrm: Some(g), + kind: ShapeKind::Group { children: vec![s], child: Xfrm::new(0.0, 0.0, 600.0, 120.0) }, + ..Default::default() + }, + None => s, + }; + render_slide(&deck_with(vec![s]), 0, &RenderOpts { scale: 0.5, ..Default::default() }) +} + +fn differing(a: &Image, b: &Image) -> usize { + a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count() +} + +#[test] +fn flipped_shapes_draw_readable_text() { + let base = Xfrm::new(180.0, 200.0, 600.0, 120.0); + let plain = flipped_text(base, None); + // Warm up: faces load lazily, so the first render may still fall back. + let plain = if differing(&plain, &flipped_text(base, None)) > 0 { flipped_text(base, None) } else { plain }; + let turned = flipped_text(Xfrm { rot: 180.0, ..base }, None); + assert!(differing(&plain, &turned) > 200, "the comparisons below must mean something"); + // flipH: text as unflipped (the text box is symmetric). flipV and both: turned 180°. + let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..base }, None)); + assert!(d < 40, "flipH mirrored the text: {d}"); + let d = differing(&turned, &flipped_text(Xfrm { flip_v: true, ..base }, None)); + assert!(d < 40, "flipV mirrored the text: {d}"); + let d = differing(&turned, &flipped_text(Xfrm { flip_h: true, flip_v: true, ..base }, None)); + assert!(d < 40, "flipH+flipV: {d}"); + // Rotation then flipH: still the rotated text, not its mirror image. + let rot = Xfrm { rot: 30.0, ..base }; + let d = differing(&flipped_text(rot, None), &flipped_text(Xfrm { flip_h: true, ..rot }, None)); + assert!(d < 40, "rotated flipH: {d}"); + // Groups: a flipped group's text isn't mirrored; a flipped child in a flipped group neither. + let child = Xfrm::new(0.0, 0.0, 600.0, 120.0); + let d = differing(&plain, &flipped_text(child, Some(Xfrm { flip_h: true, ..base }))); + assert!(d < 40, "group flipH: {d}"); + let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..child }, Some(Xfrm { flip_h: true, ..base }))); + assert!(d < 40, "flipH child in flipH group: {d}"); + let d = differing(&turned, &flipped_text(child, Some(Xfrm { flip_v: true, ..base }))); + assert!(d < 40, "group flipV: {d}"); +} + /// A text box with one run of `props` (40 pt). fn run_deck(props: deckcraft_model::RunProps) -> Presentation { let mut sh = text_shape(1, "Linked text", 40.0, 40.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a6a7951..dcd6274 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -379,14 +379,9 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To && let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts) { let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let a = x.affine(); - let rot = if l.rotation != 0.0 { - let c = l.inner.center().to_vec2(); - deckcraft_geom::Affine::translate(c) * deckcraft_geom::Affine::rotate(l.rotation.to_radians()) * deckcraft_geom::Affine::translate(-c) - } else { - deckcraft_geom::Affine::IDENTITY - }; - let m = a * rot; + // Where the text is drawn: flips don't mirror it, vertical text is turned. + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); let (s0, s1) = ts.ordered(); for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) { let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));