Merge pull request #1875 from vangel76/rfx-text

Text lays out as in After Effects: box text baseline, right-to-left text, Justify All, animator tracking, per-character 3D rotation
This commit is contained in:
BFlat
2026-10-30 04:10:22 +07:00
committed by GitHub
8 changed files with 308 additions and 27 deletions
+1 -1
View File
@@ -77,7 +77,7 @@
"name": "Tracking In",
"properties": { "tracking": 0, "opacity": 100 },
"keys": {
"tracking": [[0, 400], [1.6, 0]],
"tracking": [[0, 40], [1.6, 0]],
"opacity": [[0, 0], [0.8, 100]]
},
"ease": true,
+10 -3
View File
@@ -42,7 +42,8 @@ pub struct CharXf {
pub stroke_hsb: [f64; 3],
pub stroke_opacity: f64,
pub stroke_width: f64,
/// Tracking (pixels) added before / after the character.
/// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after
/// the character.
pub track_before: f64,
pub track_after: f64,
/// Line Anchor (percent) and the selection weight it was set with.
@@ -694,6 +695,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0);
for gi in li_start..li_end {
let x = &xfs[gi];
// An animator's Tracking Amount is in pixels, whatever the font size (After Effects
// 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike
// the Character panel's tracking in 1/1000 em.
pen += x.track_before;
shift[gi] = pen;
pen += x.track_after + vadv[gi];
@@ -804,9 +808,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
};
// Outline relative to the pivot.
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
let char_m = Mat4::rotate_z(x.rotation)
// Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z
// Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both
// land, this should call that helper (one convention, one place).
let char_m = Mat4::rotate_x(x.rotation_x)
* Mat4::rotate_y(x.rotation_y)
* Mat4::rotate_x(x.rotation_x)
* Mat4::rotate_z(x.rotation)
* Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis))
* Mat4::scale(Vec3::from(x.scale) / 100.0)
* Mat4::translate(-Vec3::from(x.anchor));
+33 -2
View File
@@ -222,7 +222,7 @@ fn tracking_line_anchor_and_character_offset() {
let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0));
let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
// 4 chars with 20 px tracking: the last glyph moves ~ 3.5 × 20 px further (before & after).
// Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after).
assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width());
assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0);
// Line Anchor 100%: tracking grows to the left.
@@ -262,7 +262,7 @@ fn tracking_grows_from_each_paragraphs_alignment() {
p.comp_mut(cid).unwrap().layers.push(l);
glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])
};
// 4 chars with 20 px of tracking each, half before and half after.
// Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after.
for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] {
let d = TextDoc { justify, ..doc("ABCD", 40.0) };
let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0)));
@@ -504,6 +504,37 @@ fn per_character_3d_projects_characters() {
assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}");
}
/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 /
/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same
/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points
/// off at edges; with Rz · Ry · Rx 23).
#[test]
fn per_character_rotation_turns_z_first_as_a_3d_layer() {
let (mut p, cid, comp) = setup(400, 300);
let mut l = text_layer(&mut p, &comp, doc("H", 80.0));
l.switches.three_d = true;
animator(&mut p, &mut l, &["rotation"], &["range"]);
let mut next = p.next_id;
let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true);
p.next_id = next;
let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap();
props.children.retain(|c| c.match_id() != "rotation");
props.children.extend(extra.drain(..).map(Into::into));
for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] {
props.get_mut(id).unwrap().value = Value::Scalar(v);
}
l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true);
p.comp_mut(cid).unwrap().layers.push(l);
let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap();
let g = &geom.glyphs[0];
let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0);
for r in 0..3 {
for c in 0..3 {
assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0);
}
}
}
#[test]
fn keyframed_selector_animates() {
let (mut p, cid, comp) = setup(400, 200);
+73 -20
View File
@@ -565,6 +565,34 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st
}
}
/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size
/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After
/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New
/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for
/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the
/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline.
fn first_ascent(face: FaceId, px: f32) -> Option<f32> {
let f = fonts::face(face);
let gid = f.glyph('d')?;
let units = crate::outline_units(face, gid)?;
let top = kurbo::Shape::bounding_box(&*units).y1 as f32;
let em = (top / f.units_per_em() * 1000.0).round() / 1000.0;
plausible_first(em, px, f.metrics(px).ascent)
}
/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one
/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font
/// ascent is used instead (the rule before the `d` height).
const MIN_D_OF_ASCENT: f32 = 0.3;
/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible:
/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px).
fn plausible_first(em: f32, px: f32, ascent: f32) -> Option<f32> {
let h = em * px;
let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent;
(h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h)
}
struct ParaLine {
range: Range<usize>,
glyphs: Vec<Glyph>,
@@ -575,6 +603,9 @@ struct ParaLine {
hang: (f32, f32),
ascent: f32,
descent: f32,
/// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the
/// line's styles at their nominal size; the font ascent where a face has no `d`).
first: f32,
/// Line advance (largest leading on the line).
advance: f32,
align: Align,
@@ -598,20 +629,25 @@ fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
/// Margins (left, right) of a paragraph's first or later lines.
fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) {
/// `rtl` is the resolved paragraph direction (an unset direction follows the text).
fn margins_for(para: &ParagraphStyle, rtl: bool, first: bool) -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
if rtl { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
}
/// The width a line may fill (`None` for point text).
fn avail_for(para: &ParagraphStyle, width: Option<f32>, first: bool) -> Option<f32> {
let (l, r) = margins_for(para, first);
fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option<f32>, first: bool) -> Option<f32> {
let (l, r) = margins_for(para, rtl, first);
width.map(|w| (w - l - r).max(1.0))
}
/// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`.
fn align_for(para: &ParagraphStyle, width: Option<f32>, last: bool) -> Align {
if (last || width.is_none()) && para.align == Align::Justify {
fn align_for(para: &ParagraphStyle, rtl: bool, width: Option<f32>, last: bool) -> Align {
if rtl && width.is_none() && para.align == Align::Justify {
// Right-to-left point text with a justified paragraph sits flush at its start, the
// right (After Effects 2026, every justify code; left-to-right not measured).
Align::Right
} else if (last || width.is_none()) && para.align == Align::Justify {
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
} else {
para.align
@@ -622,6 +658,12 @@ fn style_metrics(primaries: &[Resolved], runs: &[(Range<usize>, TextStyle)], si:
fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size())
}
/// The baseline of a box's first line for style run `si`: the `d` height, else the font ascent.
fn style_first(primaries: &[Resolved], runs: &[(Range<usize>, TextStyle)], si: usize) -> f32 {
let st = &runs[si].1;
first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent)
}
/// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run).
type CharInfo = (usize, char, char, FaceId, bool, Form, usize);
@@ -719,7 +761,7 @@ impl<'a> Para<'a> {
}
fn line_avail(&self, line: usize) -> f32 {
avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX)
avail_for(self.para, self.base_rtl, self.width, line == 0).unwrap_or(f32::MAX)
}
/// The byte ranges of the lines, for the wrap width.
@@ -736,8 +778,11 @@ impl<'a> Para<'a> {
ranges
}
/// Minimises the summed squared slack of every line but the last.
/// Minimises the summed squared slack of every line but the last; with Justify All the last
/// line is stretched too, so its slack counts (After Effects then balances the lines: 4 / 4 / 4
/// words where the other modes break 6 / 6 / 1).
fn optimal_breaks(&self, opps: &[usize]) -> Vec<Range<usize>> {
let last_counts = self.para.align == Align::Justify && self.para.justify_last == Align::Justify;
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
let m = nodes.len();
let mut best = vec![(f32::INFINITY, 0usize); m];
@@ -757,7 +802,7 @@ impl<'a> Para<'a> {
let slack = maxw - w;
let cost = if over {
1e12 + w
} else if j == m - 1 {
} else if j == m - 1 && !last_counts {
0.0
} else {
slack * slack
@@ -802,8 +847,8 @@ impl<'a> Para<'a> {
/// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one.
fn place_line(&self, li: usize, r: Range<usize>, last: bool) -> ParaLine {
let align = align_for(self.para, self.width, last);
let avail = avail_for(self.para, self.width, li == 0);
let align = align_for(self.para, self.base_rtl, self.width, last);
let avail = avail_for(self.para, self.base_rtl, self.width, li == 0);
let (ce, tw) = self.trailing_ws(&r);
let hang = self.hang_of(&r, ce);
let content = self.range_w(r.start..ce);
@@ -819,7 +864,7 @@ impl<'a> Para<'a> {
}
let placed = self.place_glyphs(&r, space_extra, ce);
let carets = self.carets(&r, &placed.extents, placed.pen);
let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs);
let (asc, desc, adv, first) = self.line_metrics(&r, &placed.glyphs);
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
ParaLine {
range: self.base + r.start..self.base + r.end,
@@ -830,10 +875,11 @@ impl<'a> Para<'a> {
hang,
ascent: asc,
descent: desc,
first,
advance: adv,
align,
avail,
margins: margins_for(self.para, li == 0),
margins: margins_for(self.para, self.base_rtl, li == 0),
underline: placed.underline,
}
}
@@ -938,8 +984,8 @@ impl<'a> Para<'a> {
}
/// Ascent, descent and advance of the line: the tallest face and the largest leading on it.
fn line_metrics(&self, r: &Range<usize>, glyphs: &[Glyph]) -> (f32, f32, f32) {
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
fn line_metrics(&self, r: &Range<usize>, glyphs: &[Glyph]) -> (f32, f32, f32, f32) {
let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
let char_start = self.chars.partition_point(|c| c.0 < r.start);
let char_end = self.chars.partition_point(|c| c.0 < r.end);
let mut seen: Vec<usize> = self.chars[char_start..char_end].iter().map(|c| c.6).collect();
@@ -954,6 +1000,7 @@ impl<'a> Para<'a> {
asc = asc.max(m.ascent + rise.max(0.0));
desc = desc.max(m.descent - rise.min(0.0));
adv = adv.max(self.runs[si].1.line_advance());
first = first.max(style_first(self.primaries, self.runs, si));
}
for g in glyphs {
if g.face != self.primaries[g.run].face {
@@ -962,7 +1009,7 @@ impl<'a> Para<'a> {
desc = desc.max(m.descent);
}
}
(asc, desc, adv)
(asc, desc, adv, first)
}
}
@@ -1037,6 +1084,8 @@ fn paragraph(
if text.is_empty() {
let si = run_at(runs, base);
let pm = style_metrics(primaries, runs, si);
// No text to resolve a direction from: the explicit one.
let rtl = para.rtl == Some(true);
return vec![ParaLine {
range: base..base,
glyphs: vec![],
@@ -1047,9 +1096,10 @@ fn paragraph(
ascent: pm.ascent,
descent: pm.descent,
advance: runs[si].1.line_advance(),
align: align_for(para, width, true),
avail: avail_for(para, width, true),
margins: margins_for(para, true),
first: style_first(primaries, runs, si),
align: align_for(para, rtl, width, true),
avail: avail_for(para, rtl, width, true),
margins: margins_for(para, rtl, true),
underline: None,
}];
}
@@ -1120,7 +1170,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
for (i, (pi, pl)) in plines.into_iter().enumerate() {
let para = para_at(pi);
if i == 0 {
baseline = if width.is_some() { pl.ascent } else { 0.0 };
// Vertical type keeps its first column at the font ascent (not measured in AE).
let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical)
|| runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start);
baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 };
} else {
let prev_para = lay.lines.last().map_or(pi, |l| l.para);
let mut adv = pl.advance + para.leading;
+137
View File
@@ -357,3 +357,140 @@ fn named_instance_sets_the_base_variations() {
let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default());
assert!(inter.glyphs.iter().all(|g| g.variations == 0));
}
/// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole
/// thousandths of an em) below the box top, at the nominal size also for superscript, whose
/// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the
/// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the
/// superscript glyphs 15.8 px low.
#[test]
fn box_text_first_baseline_is_the_d_height_as_after_effects() {
let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() };
// The rule on a bundled face (works everywhere).
for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] {
let s = TextStyle { family: family.into(), ..st(size) };
let r = fonts::resolve(family, "Regular");
let f = fonts::face(r.face);
let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap();
let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0;
let l = layout("Hxg", &s, &boxed);
assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size);
assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent");
}
// After Effects' numbers, where the real fonts are installed.
for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] {
let r = fonts::resolve(family, "Regular");
if fonts::face(r.face).info.family != family {
continue;
}
for (size, script, glyph_baseline) in
[(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)]
{
let s = TextStyle { family: family.into(), script, ..st(size) };
let l = layout("Hxg", &s, &boxed);
let drawn = l.glyphs[0].y;
assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}");
}
}
}
/// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the
/// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used.
#[test]
fn a_short_d_falls_back_to_the_font_ascent() {
// Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em.
let near = |v: Option<f32>, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3);
assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0));
// A `d` a tenth of the ascent: not a lowercase ascender.
assert_eq!(plausible_first(0.09, 100.0, 90.0), None);
assert_eq!(plausible_first(0.26, 100.0, 90.0), None);
assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0));
// Broken values stay rejected; an unusable ascent does not reject a sane `d`.
for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] {
assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}");
}
assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0));
// The bundled faces keep the `d` rule.
for family in ["Inter", "Noto Serif"] {
let r = fonts::resolve(family, "Regular");
assert!(first_ascent(r.face, 100.0).is_some(), "{family}");
}
}
/// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that
/// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the
/// stretched last line is not a lone word; word positions then match AE to 1 px.
#[test]
fn justify_all_balances_the_lines_with_the_every_line_composer() {
let text = "one two three four five six seven eight nine ten eleven twelve";
let s = st(30.0);
let words = |l: &Layout| -> Vec<usize> { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() };
let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() };
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]);
let (wl, wa) = (words(&left), words(&all));
assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}");
let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0));
assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})");
}
/// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified
/// code (justify last left / centre / right, justify all) sits flush right at its anchor, as
/// right-aligned text does.
#[test]
fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() {
let text = "Right to left";
let s = st(30.0);
let at = |align: Align, last: Align| {
let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
(l.lines[0].x, l.lines[0].x + l.lines[0].width)
};
let right = at(Align::Right, Align::Left);
for last in [Align::Left, Align::Center, Align::Right, Align::Justify] {
let got = at(Align::Justify, last);
assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}");
}
}
/// The same holds when the direction is unset and the text itself resolves right to left.
#[test]
fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() {
let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}";
let s = st(30.0);
let at = |align: Align| {
let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
(l.lines[0].x, l.lines[0].x + l.lines[0].width)
};
let (right, got) = (at(Align::Right), at(Align::Justify));
assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}");
}
/// Indents follow the resolved direction too: detected right-to-left text puts its start
/// indent on the right, as an explicit right-to-left paragraph does.
#[test]
fn detected_rtl_text_takes_its_indents_on_the_rtl_sides() {
let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}";
let s = st(30.0);
let at = |rtl: Option<bool>| {
let p = ParagraphStyle { align: Align::Justify, justify_last: Align::Left, indent_start: 20.0, rtl, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
(l.lines[0].x, l.lines[0].x + l.lines[0].width)
};
let (explicit, detected) = (at(Some(true)), at(None));
assert!((detected.0 - explicit.0).abs() < 1e-3 && (detected.1 - explicit.1).abs() < 1e-3, "{detected:?} vs {explicit:?}");
}
/// Only the first line's own runs decide whether box text starts at the font ascent: a
/// vertical run further down leaves a horizontal first line on its `d`-height baseline.
#[test]
fn a_later_vertical_run_keeps_the_first_line_baseline() {
let text = "Top\nside";
let horiz = st(40.0);
let vert = TextStyle { vertical: true, ..horiz.clone() };
let p = ParagraphStyle { width: Some(400.0), ..Default::default() };
let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p));
let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]);
assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline);
}
+3 -1
View File
@@ -659,7 +659,9 @@ mod tests {
let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() };
let l = layout_doc(&doc);
let (a, _) = l.caret(0);
assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}");
// The first baseline sits the `d` height below the box top (After Effects), so the
// caret, which spans the font's full ascent, starts a little above the box.
assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}");
// Vertical: characters run down a column, carets are horizontal.
let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() });
let ys: Vec<f64> = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect();
+12
View File
@@ -71,6 +71,15 @@ impl Mode {
pub fn combine(self, acc: f64, v: f64) -> f64 {
let r = match self {
Mode::Add => acc + v,
// Subtracting more than is selected leaves nothing, not a negative selection (After
// Effects 2026: a Subtract selector over unselected characters leaves their
// Tracking at 0, EXPORTER read-back t8).
// Intended for every animator property, not only Tracking: selectors combine into one
// selection per character for the whole animator (`render::text::animator_selection`),
// which then scales each of its properties, as in After Effects, where selectors
// belong to the animator. The read-back observed that selection through Tracking;
// Opacity, Position and the rest were not measured separately.
Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0),
Mode::Subtract => acc - v,
Mode::Intersect => acc * v,
Mode::Min => acc.min(v),
@@ -352,6 +361,9 @@ mod tests {
// A lone Subtract inverts.
assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0);
assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0);
// Subtracting from what is not selected leaves 0, not a negative selection (AE t8).
assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0);
assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0);
assert_eq!(Mode::from_index(5), Mode::Difference);
}
+39
View File
@@ -71,6 +71,45 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two
test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys,
including a timing change. AE's output stays local; only the script and these scores are tracked.
## Justify All with the every-line composer
Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6):
with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not
a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's
every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now
counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test:
`justify_all_balances_the_lines_with_the_every_line_composer`.
## Right-to-left point text with a justified paragraph
Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left
point text whose paragraph has a justified code (justify last left / centre / right, justify all)
sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line
side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured).
Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open:
AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in
left-to-right text with the same font), so a 300 px box fits a word more per line.
## Text animator tracking
Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in
pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring
characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector
over characters the first selector leaves out keeps them at 0 instead of tracking them negatively.
With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector
over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text
preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not
checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`,
`modes_combine`.
## Per-character 3D rotation order
Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character
3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz
(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points
off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test:
`per_character_rotation_turns_z_first_as_a_3d_layer`.
## Rectangle path start point
Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim