From 1ebfef77c49956961c37f3616a67c82a9116df5a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:01:24 -0400 Subject: [PATCH] M13.5: vertical Roman, tate-chu-yoko, forced LTR, animated caret, Lottie style runs Text: vertical type turns Roman characters on their side (CJK stays upright) unless Standard Vertical Roman Alignment is on; Tate-Chu-Yoko sets selected characters horizontally in a one-em cell of the column (layer.setText tateChuYoko / verticalRomanUpright, Character panel toggles). A Left-to-Right paragraph is now forced LTR instead of auto-detected. The editing caret follows animators and text on a path (render::text::caret_maps). Lottie export writes per-character style runs as index-unit range animators. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_tail.rs | 22 +++++ crates/keyframe/src/text_doc.rs | 20 +++- crates/lottie/src/tests.rs | 24 +++++ crates/lottie/src/text.rs | 64 +++++++++++++ crates/render/src/text.rs | 42 ++++++++- crates/render/src/text/tests.rs | 36 ++++++++ crates/text/src/lib.rs | 111 +++++++++++++++++++++-- crates/ui-egui/src/panels/text_panels.rs | 16 ++++ crates/ui-egui/src/panels/viewer_text.rs | 12 ++- 9 files changed, 335 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 7da7319f..17f2a6b6 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -260,6 +260,28 @@ fn keyframe_label_groups() { assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err()); } +#[test] +fn tate_chu_yoko_via_set_text_with_undo_and_serde() { + let mut s = comp(400, 400); + let t = s.execute("layer.newText", json!({"text": "A12B", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": t, "vertical": true})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "range": [1, 3], "tateChuYoko": true})).unwrap(); + let doc = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap(); + assert!(doc.style_at(1).tate_chu_yoko && doc.style_at(2).tate_chu_yoko && !doc.style_at(0).tate_chu_yoko); + let lay = effectcraft_text::layout_doc(&doc); + assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row"); + let back = roundtrip(&s); + let effectcraft_keyframe::Value::Text(d) = &back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value + else { + panic!() + }; + assert!(d.style_at(1).tate_chu_yoko); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(1).tate_chu_yoko); + s.execute("layer.setText", json!({"layer": t, "verticalRomanUpright": true})).unwrap(); + assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright); +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 9652b91a..2e9d5c6e 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -172,6 +172,11 @@ pub struct CharStyle { pub baseline: BaselineOption, pub kerning: Kerning, pub ligatures: bool, + /// Vertical type: set these characters horizontally within the column (Tate-Chu-Yoko). + pub tate_chu_yoko: bool, + /// Vertical type: keep Roman (half-width) characters upright instead of turning them on + /// their side (Character panel menu ▸ Standard Vertical Roman Alignment). + pub vertical_roman_upright: bool, } impl Default for CharStyle { @@ -250,6 +255,9 @@ pub struct TextDoc { pub kerning: Kerning, #[serde(default = "yes")] pub ligatures: bool, + /// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]). + pub tate_chu_yoko: bool, + pub vertical_roman_upright: bool, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -299,6 +307,8 @@ impl Default for TextDoc { baseline: BaselineOption::Normal, kerning: Kerning::Metrics, ligatures: true, + tate_chu_yoko: false, + vertical_roman_upright: false, indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -383,6 +393,8 @@ impl TextDoc { baseline: self.baseline, kerning: self.kerning, ligatures: self.ligatures, + tate_chu_yoko: self.tate_chu_yoko, + vertical_roman_upright: self.vertical_roman_upright, } } @@ -408,6 +420,8 @@ impl TextDoc { self.baseline = s.baseline; self.kerning = s.kerning; self.ligatures = s.ligatures; + self.tate_chu_yoko = s.tate_chu_yoko; + self.vertical_roman_upright = s.vertical_roman_upright; } /// The base (first paragraph's) settings. @@ -792,6 +806,8 @@ pub const CHAR_ATTRS: &[&str] = &[ "subscript", "kerning", "ligatures", + "tateChuYoko", + "verticalRomanUpright", ]; /// Paragraph attribute keys of `layer.setText`. @@ -851,6 +867,8 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result s.kerning = Kerning::parse(v).ok_or("kerning: expected metrics|optical|number")?, "ligatures" => s.ligatures = flag(key, v)?, + "tateChuYoko" => s.tate_chu_yoko = flag(key, v)?, + "verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?, _ => return Ok(false), } Ok(true) @@ -896,7 +914,7 @@ pub fn char_style_json(s: &CharStyle) -> J { "tracking": s.tracking, "leading": s.leading.map_or(J::from("auto"), J::from), "baselineShift": s.baseline_shift, "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), - "ligatures": s.ligatures, + "ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright, }) } diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 0641883f..7a3ce4e5 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -700,3 +700,27 @@ fn fixture_legacy_keyframes_and_masks() { assert!(matches!(masks[1].kind, GroupKind::Mask { mode: MaskMode::Subtract, inverted: true, .. })); assert!(not_blank(&q, c, s(0.9))); } + +#[test] +fn text_style_runs_export_as_range_animators() { + let (mut p, cid) = setup(); + let c = comp(&p, cid); + let mut l = build::layer(&mut p, &c, "Runs", LayerSource::Text, (c.width, c.height), None); + let mut doc = TextDoc { text: "Hello".into(), size: 30.0, fill: [1.0, 1.0, 1.0, 1.0], ..Default::default() }; + doc.apply_style(1..3, |st| { + st.fill = [1.0, 0.0, 0.0, 1.0]; + st.baseline_shift = 6.0; + }); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc)); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 60.0, 0.0]); + push(&mut p, cid, l); + let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default()); + let a = &json["layers"][0]["t"]["a"][0]; + assert_eq!(a["s"]["r"], json!(2), "{a}"); + assert_eq!(a["s"]["s"]["k"], json!(1)); + assert_eq!(a["s"]["e"]["k"], json!(3)); + assert_eq!(a["a"]["fc"]["k"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(a["a"]["p"]["k"], json!([0.0, -6.0, 0.0])); + // Played back as an animator, it looks the same. + compare_renders(&p, cid, &q, nc, 2.0 / 255.0); +} diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 59fe6fa9..75489688 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -171,6 +171,14 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> } } let mut animators = vec![]; + // Per-character styles: Lottie text documents have one style, so every run that differs + // from the base becomes a text animator whose range selector covers exactly its characters + // (index units). Fonts and faux styles per run can't be expressed. + if let Some(st) = tg.get("sourceText") + && let Value::Text(d) = &st.value + { + animators.extend(style_run_animators(ex, d, label)); + } if let Some(list) = tg.sub("animators") { for a in list.groups() { animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name))); @@ -189,6 +197,62 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}}) } +/// Text animators reproducing the style runs of `d` (fill, stroke, stroke width, size, +/// tracking, baseline shift) over their character ranges. +fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { + let base = d.base_style(); + let mut out = vec![]; + let mut start = 0usize; + let mut warned = false; + for run in d.runs() { + let (a, b) = (start, start + run.len); + start = b; + let s = &run.style; + if *s == base || run.len == 0 { + continue; + } + let k = |v: Json| json!({"a": 0, "k": v}); + let mut props = Map::new(); + if s.apply_fill && s.fill != base.fill { + props.insert("fc".into(), k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0]))); + } + if s.apply_stroke && s.stroke != base.stroke { + props.insert("sc".into(), k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0]))); + } + if (s.stroke_width - base.stroke_width).abs() > 1e-9 { + props.insert("sw".into(), k(json!(s.stroke_width - base.stroke_width))); + } + if (s.size - base.size).abs() > 1e-9 && base.size > 0.0 { + let p = s.size / base.size * 100.0; + props.insert("s".into(), k(json!([p, p, 100.0]))); + } + if (s.tracking - base.tracking).abs() > 1e-9 { + // Lottie tracking is in 1/1000 em like the Character panel's. + props.insert("t".into(), k(json!(s.tracking - base.tracking))); + } + if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { + props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); + } + let lost = s.font != base.font || s.style != base.style || s.faux_bold != base.faux_bold || s.faux_italic != base.faux_italic; + if lost && !warned { + ex.warn(format!("{label}: per-character fonts and faux styles are not part of Lottie text (base font used)")); + warned = true; + } + if props.is_empty() { + continue; + } + out.push(json!({ + "nm": format!("Style {a}–{b}"), + "s": { + "t": 0, "xe": k(json!(0)), "ne": k(json!(0)), "a": k(json!(100)), "b": 1, "rn": 0, "sh": 1, + "r": 2, "m": k(json!(1)), "s": k(json!(a)), "e": k(json!(b)), "o": k(json!(0)), + }, + "a": Json::Object(props), + })); + } + out +} + fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json { let mut props = Map::new(); if let Some(pg) = a.sub("properties") { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 37b0f775..a5399dc6 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -404,6 +404,11 @@ pub struct PlacedGlyph { pub stroke_width: f64, /// The character's style fills it (Character panel fill on). pub apply_fill: bool, + /// Index of the character in the text. + pub char_index: usize, + /// Where the pivot sits in the unanimated layout (layer space): `m · (p − rest)` carries a + /// point of the static layout to where the animation puts this character. + pub rest: [f64; 2], } impl PlacedGlyph { @@ -463,6 +468,30 @@ fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeas Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0))) } +/// For every caret position `0..=chars` of the layer's text, the map from the static layout +/// (layer space, what [`TextLayout::caret`] returns) to where animators and Path Options put +/// the characters: the caret follows the character before it (or the first one). +pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec { + let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] }; + let mut by_char: Vec> = vec![None; chars + 1]; + for g in &geom.glyphs { + if let Some(slot) = by_char.get_mut(g.char_index) { + *slot = Some(g.m2() * Mat3::translate(effectcraft_geom::vec2(-g.rest[0], -g.rest[1]))); + } + } + let first = by_char.iter().flatten().next().copied().unwrap_or(Mat3::IDENTITY); + let mut out = Vec::with_capacity(chars + 1); + let mut last = first; + for ci in 0..=chars { + // Caret ci sits after character ci − 1. + if let Some(Some(m)) = ci.checked_sub(1).map(|p| by_char[p]) { + last = m; + } + out.push(if ci == 0 { by_char[0].unwrap_or(first) } else { last }); + } + out +} + /// Lay out and animate a text layer. pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let doc = source_text(ctx, layer)?; @@ -642,7 +671,18 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity); let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity); let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 }; - glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill }); + let rest = [pivot[0] - (o[0] - g.origin.x), pivot[1] - (o[1] - g.origin.y)]; + glyphs.push(PlacedGlyph { + local, + m, + xf: x, + fill, + stroke, + stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + apply_fill: st.apply_fill, + char_index: g.char_index, + rest, + }); } Some(TextGeom { doc, glyphs, fill_stroke, blend }) } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 9d7effa0..b7073667 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -416,3 +416,39 @@ fn keyframed_selector_animates() { assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]); assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]); } + +#[test] +fn caret_follows_animators_and_path_text() { + let (mut p, cid, comp) = setup(400, 400); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0)); + let a = animator(&mut p, &mut l, &["position"], &["range"]); + set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let c = ctx(&p, cid, 0.0); + let ly = &p.comp(cid).unwrap().layers[0]; + let lay = layout_doc(&source_text(&c, ly).unwrap()); + let maps = caret_maps(&c, ly, lay.chars); + assert_eq!(maps.len(), 5); + for (ci, m) in maps.iter().enumerate() { + let (top, _) = lay.caret(ci); + let q = m.apply(vec2(top.x, top.y)); + assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}"); + } + // On a vertical path (x = 100, running down) the caret turns with the characters and sits + // on the path. + let (mut p, cid, comp) = setup(400, 400); + let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0)); + mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false); + set(&mut l, "text/pathOptions/path", Value::Enum(1)); + p.comp_mut(cid).unwrap().layers.push(l); + let c = ctx(&p, cid, 0.0); + let ly = &p.comp(cid).unwrap().layers[0]; + let lay = layout_doc(&source_text(&c, ly).unwrap()); + let maps = caret_maps(&c, ly, lay.chars); + let (t, b) = lay.caret(2); + let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y))); + assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}"); + assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}"); + let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::>(); + assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1bd93920..229b7636 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -170,8 +170,9 @@ pub fn paragraph_style(p: &ParaStyle, width: Option) -> ParagraphStyle { justify_last, leading: 0.0, width, - // Left-to-right paragraphs keep the Unicode default (first strong character). - rtl: (p.direction == Direction::Rtl).then_some(true), + // The chosen direction is the paragraph's base direction (a left-to-right paragraph + // starting with Arabic or Hebrew still runs left to right, as in After Effects). + rtl: Some(p.direction == Direction::Rtl), indent_start: p.indent_left as f32, indent_end: p.indent_right as f32, indent_first: p.indent_first as f32, @@ -182,6 +183,15 @@ pub fn paragraph_style(p: &ParaStyle, width: Option) -> ParagraphStyle { } } +/// Characters set upright in vertical type: CJK ideographs, kana, hangul, full-width forms and +/// CJK punctuation. Everything else (Roman) turns on its side unless asked to stay upright. +pub fn is_cjk(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0x30FF | 0x3100..=0x31FF | 0x3200..=0x4DBF | 0x4E00..=0x9FFF + | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF + | 0x20000..=0x3FFFF) +} + /// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns /// running right to left), characters kept upright. const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); @@ -243,7 +253,13 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { for (li, line) in lay.lines.iter().enumerate() { let o = to_layer * Point::new(line.x as f64, line.baseline as f64); out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]); - for gi in line.glyphs.clone() { + // Vertical type: Tate-Chu-Yoko groups (consecutive glyphs of tcy runs) take one em of + // the column; everything after them moves up by the rest of their width. + let tcy = |gi: usize| doc.vertical && styles.get(lay.glyphs[gi].run).is_some_and(|s| s.tate_chu_yoko); + let mut shift = 0.0f64; + let mut group: Option<(f64, f64, f64)> = None; // (start x, width, em) in layout space + let line_glyphs: Vec = line.glyphs.clone().collect(); + for (k, gi) in line_glyphs.iter().copied().enumerate() { let g = &lay.glyphs[gi]; let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0); let src = text[g.cluster..].chars().next().unwrap_or(' '); @@ -252,13 +268,37 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { let mut path = glyph_outline(g); let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width); let advance = (next_x - g.x) as f64; - if doc.vertical { - // Keep the character upright around its centre. - let c = Point::new(advance / 2.0, -0.35 * g.size as f64); - let rc = ROT90 * c; - path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path; + let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64); + if doc.vertical && tcy(gi) { + // Start of a group: measure it. + if group.is_none() { + let mut w = 0.0; + for &gj in line_glyphs[k..].iter().take_while(|&&gj| tcy(gj)) { + let nx = lay.glyphs.get(gj + 1).filter(|_| line.glyphs.contains(&(gj + 1))).map(|n| n.x).unwrap_or(line.x + line.width); + w += (nx - lay.glyphs[gj].x) as f64; + } + group = Some((g.x as f64, w, g.size as f64)); + } + let (x0, w, em) = group.unwrap_or((g.x as f64, advance, g.size as f64)); + // Upright and horizontal, centred across the column in an em-high cell. + let centre = to_layer * Point::new(x0 - shift + em / 2.0, g.y as f64 - 0.35 * em); + origin = Point::new(centre.x - w / 2.0 + (g.x as f64 - x0), centre.y + 0.35 * em); + if line_glyphs.get(k + 1).is_none_or(|&n| !tcy(n)) { + shift += w - em; + group = None; + } + } else if doc.vertical { + let upright = is_cjk(src) || st.is_some_and(|s| s.vertical_roman_upright); + if upright { + // Keep the character upright around its centre. + let c = Point::new(advance / 2.0, -0.35 * g.size as f64); + let rc = ROT90 * c; + path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path; + } else { + // Roman characters turn on their side with the column. + path = ROT90 * path; + } } - let origin = to_layer * Point::new(g.x as f64, g.y as f64); if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) { b[0] = b[0].min(origin.x + r.x0); b[1] = b[1].min(origin.y + r.y0); @@ -438,6 +478,59 @@ mod tests { assert_eq!(v.hit(p0.midpoint(p1)), 1); } + #[test] + fn vertical_roman_rotates_and_tate_chu_yoko_sets_upright_groups() { + let wide = |g: &CharGlyph| { + let r = kurbo::Shape::bounding_box(&g.path); + (r.width(), r.height()) + }; + // "H" sideways in a vertical column: wider than tall (it is taller than wide upright). + let doc = TextDoc { text: "HI".into(), size: 40.0, vertical: true, ..Default::default() }; + let v = layout_doc(&doc); + let (w, h) = wide(&v.glyphs[0]); + assert!(w > h, "rotated: {w}×{h}"); + // Standard Vertical Roman Alignment keeps it upright. + let mut up = doc.clone(); + up.apply_style_all(|s| s.vertical_roman_upright = true); + let (w, h) = wide(&layout_doc(&up).glyphs[0]); + assert!(h > w, "upright: {w}×{h}"); + // CJK stays upright. + let cjk = layout_doc(&TextDoc { text: "日本".into(), size: 40.0, vertical: true, ..Default::default() }); + assert!(cjk.glyphs.iter().all(|g| g.origin.x.is_finite())); + // Tate-Chu-Yoko on "12" in "A12B": the digits sit side by side on one row of the column + // (same y, increasing x) and the group takes one em of the column. + let mut t = TextDoc { text: "A12B".into(), size: 40.0, vertical: true, ..Default::default() }; + let plain = layout_doc(&t); + t.apply_style(1..3, |s| s.tate_chu_yoko = true); + let tcy = layout_doc(&t); + let (one, two) = (&tcy.glyphs[1], &tcy.glyphs[2]); + assert!((one.origin.y - two.origin.y).abs() < 1e-6, "{:?} {:?}", one.origin, two.origin); + assert!(two.origin.x > one.origin.x + 5.0); + let (w, h) = wide(one); + assert!(h > w, "digits upright"); + let w = one.advance + two.advance; + let moved = tcy.glyphs[3].origin.y - plain.glyphs[3].origin.y; + // (Within kerning, which no longer applies across the run boundaries.) + assert!((moved - (40.0 - w)).abs() < 2.5, "the group takes one em: moved {moved}, width {w}"); + // The group is centred across the column (x of the column centre line). + let mid = (one.origin.x + two.origin.x + two.advance) / 2.0; + assert!((mid - plain.glyphs[0].origin.x - 0.35 * 40.0).abs() < 3.0, "{mid}"); + } + + #[test] + fn ltr_paragraph_direction_is_forced() { + // Arabic first: Left-to-Right keeps a left-to-right base (the Latin word stays left). + let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} abc"; + let ltr = layout_doc(&TextDoc { text: text.into(), size: 30.0, ..Default::default() }); + let mut r = TextDoc { text: text.into(), size: 30.0, ..Default::default() }; + r.direction = effectcraft_keyframe::text_doc::Direction::Rtl; + let rtl = layout_doc(&r); + let x_of = |l: &TextLayout, c: char| l.glyphs.iter().find(|g| g.ch == c).map(|g| g.origin.x).unwrap(); + let first_ar = '\u{0645}'; + assert!(x_of(<r, 'a') > x_of(<r, first_ar), "LTR: Arabic run first, then abc"); + assert!(x_of(&rtl, 'a') < x_of(&rtl, first_ar), "RTL: abc on the left"); + } + #[test] fn centered_point_text_straddles_origin() { let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() }; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index edb1132f..48c762cb 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -232,6 +232,22 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("character.ligatures", lr, "Ligatures"); p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim); + // Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu). + if doc.vertical { + y += 22.0; + for (i, (key, label, on)) in + [("tateChuYoko", "Tate-Chu-Yoko", doc.tate_chu_yoko), ("verticalRomanUpright", "Standard Vertical Roman", doc.vertical_roman_upright)] + .into_iter() + .enumerate() + { + let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0)); + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled { + actions.push(json!({key: !on})); + } + app.auto.add(&format!("character.{key}"), cr, label); + p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim); + } + } match &target { None => { p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index e97f5eda..80ee1209 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -54,6 +54,8 @@ struct Edited { /// Layer → comp. m: Mat3, inv: Mat3, + /// Per caret position: static layout → animated / on-path character placement. + carets: Vec, } impl Edited { @@ -111,7 +113,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option let lay = layout_doc(&doc); let m = super::viewer::l2c(ectx, &layer).0; let inv = m.inverse()?; - Some(Edited { layer, doc, lay, m, inv }) + let carets = effectcraft_engine::render::text::caret_maps(ectx, &layer, lay.chars); + Some(Edited { layer, doc, lay, m, inv, carets }) } /// The topmost text layer whose text is under screen point `s`. @@ -494,7 +497,14 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma } } // Caret (blinking) and IME composition. + // The caret follows the animated layout and text on a path. let (a, b) = e.lay.caret(caret); + let cm = e.carets.get(caret).copied().unwrap_or(Mat3::IDENTITY); + let anim = |p: Point| { + let q = cm.apply(gv2(p.x, p.y)); + Point::new(q.x, q.y) + }; + let (a, b) = (anim(a), anim(b)); let (sa, sb) = (to_s(a), to_s(b)); let now = ui.input(|i| i.time); let preedit: Option = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));