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:
parent
b1e6867c0b
commit
ddbd71e297
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue