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));