Shapes: flipped shapes keep their text readable (#35)

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 <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
LloydNicholson 2026-10-10 12:18:46 +02:00 committed by GitHub
parent b1e6867c0b
commit ddbd71e297
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 131 additions and 26 deletions

View File

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

View File

@ -1258,10 +1258,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
/// Text position under slide point `p` in shape `sh`.
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (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)
}

View File

@ -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 {

View File

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

View File

@ -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 };

View File

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

View File

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