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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 00:01:24 -04:00
1 parent b03af6a979
commit 1ebfef77c4
9 files changed
+335 -12

No files matched your search

+22
View File
@@ -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);
+19 -1
View File
@@ -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<bool, Stri
}
"kerning" => 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,
})
}
+24
View File
@@ -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);
}
+64
View File
@@ -171,6 +171,14 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec<Json>) ->
}
}
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>) ->
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<Json> {
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") {
+41 -1
View File
@@ -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<Mat3> {
let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] };
let mut by_char: Vec<Option<Mat3>> = 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<TextGeom> {
let doc = source_text(ctx, layer)?;
@@ -642,7 +671,18 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
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 })
}
+36
View File
@@ -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::<Vec<_>>();
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
}
+102 -9
View File
@@ -170,8 +170,9 @@ pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> 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<f32>) -> 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<usize> = 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(&ltr, 'a') > x_of(&ltr, 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() };
+16
View File
@@ -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);
+11 -1
View File
@@ -54,6 +54,8 @@ struct Edited {
/// Layer → comp.
m: Mat3,
inv: Mat3,
/// Per caret position: static layout → animated / on-path character placement.
carets: Vec<Mat3>,
}
impl Edited {
@@ -111,7 +113,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited>
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<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));