mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
b03af6a979
commit
1ebfef77c4
9 files changed
+335
-12
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(<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() };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")));
|
||||
|
||||
Reference in new issue
Block a user