From a7255f76085e31290d48c129e628c9d8db0a0abe Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 01:36:25 +0200 Subject: [PATCH 01/10] Text: box text's first baseline is the height of "d" below the box top, as After Effects After Effects puts the first baseline of paragraph (box) text the height of the font's lowercase "d" (in whole thousandths of an em) below the box top, at the text's nominal size; superscript keeps the line there and only raises its glyphs. EffectCraft used the font ascent of the drawn size plus the superscript rise. Measured in AE 2026 (case 818, box at y -200): Times New Roman 100 px -130.50 (EffectCraft -110.9), 50 px -165.25, superscript -163.80 (EffectCraft drew it 15.8 px lower), Arial 100 px -128.40, superscript -161.70; now within 0.15 px. 517 (four wrapped lines, Times New Roman superscript): the line bands now land on AE's rows exactly (336-388, 434-486, 532-584, 643-682; were 16 px lower). A face without a "d" outline, and vertical type (not measured), keep the font ascent. The paragraph-box test now expects the caret (full ascent) to start a little above the box. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 68 +++++++++++++++++++++++++++++++++++++-- crates/text/src/lib.rs | 4 ++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1ae..0ec5c7347 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,22 @@ 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 { + 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; + let h = em * px; + (h.is_finite() && h > 0.0 && em < 4.0).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +591,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, @@ -611,6 +630,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let st = &runs[si].1; fonts::face(primaries[si].face).metrics(st.glyph_size()) }; + let style_first = |si: usize| { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) + }; let eff_align = |last: bool| -> Align { if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } @@ -630,6 +653,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar hang: (0.0, 0.0), ascent: pm.ascent, descent: pm.descent, + first: style_first(si), advance: runs[si].1.line_advance(), align: eff_align(true), avail: avail_for(true), @@ -899,7 +923,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar carets.sort_by_key(|c| c.0); carets.dedup_by_key(|c| c.0); // Line metrics: the tallest face and the largest leading on the line. - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let mut seen: Vec = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect(); seen.sort_unstable(); seen.dedup(); @@ -912,6 +936,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(runs[si].1.line_advance()); + first = first.max(style_first(si)); } for g in &glyphs { if g.face != primaries[g.run].face { @@ -930,6 +955,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, @@ -985,7 +1011,9 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, 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.iter().any(|r| r.1.vertical); + 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; @@ -1213,6 +1241,42 @@ mod tests { assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01)); } + /// 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}"); + } + } + } + #[test] fn newlines_make_paragraphs_and_leading_adds() { let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 187167527..71db4a9ea 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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 = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); From 8bc57bc7ad23d822ff3096fd20f3ed14b2616742 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:38:11 +0200 Subject: [PATCH 02/10] Text: Justify All balances the lines with the every-line composer, as After Effects With Justify All After Effects stretches the paragraph's last line too, and its every-line composer breaks the paragraph so that line is not a lone word: a 400 px box that the other justifications break into 6 / 6 / 1 words breaks 4 / 4 / 4 (AE 2026, EXPORTER read-back t6). EffectCraft's composer left the last line's slack out of its cost, so "twelve" stood alone on a stretched line; it now counts that slack for Justify All and every word starts within 1 px of AE's (what remains in that box is the known box-text baseline offset). Other justifications and the single-line composer are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 24 ++++++++++++++++++++++-- docs/fidelity/README.md | 9 +++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0ec5c7347..d8bdee4fd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -761,7 +761,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let opps: Vec = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect(); let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX); if para.every_line && opps.len() > 1 && opps.len() <= 1500 { - // Minimise the summed squared slack of every line but the last. + // Minimise 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). + let last_counts = para.align == Align::Justify && para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -781,7 +784,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar 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 @@ -1464,6 +1467,23 @@ mod tests { assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5)); } + /// 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 { 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:?})"); + } + #[test] fn hanging_punctuation_overhangs_the_box() { let s = st(40.0); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..6c59acd43 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,15 @@ 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`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 83b134cdc8650e0324ab125f9f139fb77e964eac Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:43:27 +0200 Subject: [PATCH 03/10] Text: right-to-left point text with a justified paragraph sits flush right, as After Effects After Effects 2026 lays out right-to-left point text whose paragraph carries a justified code (justify last left / centre / right, justify all) flush at its start, the right, like right-aligned text (EXPORTER read-back t7: point rows with codes 3-6 at -152..-8 from the anchor). EffectCraft placed it by the last-line side (left / centre / left). Left-to-right point text and box text are unchanged; AE's slightly wider right-to-left box wrapping (1-2 px narrower word gaps) stays open. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 25 ++++++++++++++++++++++++- docs/fidelity/README.md | 10 ++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index d8bdee4fd..3ce08b84f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -635,7 +635,11 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; let eff_align = |last: bool| -> Align { - if (last || width.is_none()) && para.align == Align::Justify { + if rtl_para && 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 @@ -1499,6 +1503,25 @@ mod tests { assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right"); } + /// 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:?}"); + } + } + #[test] fn rtl_paragraph_carets_run_right_to_left() { let text = "\u{5d0}\u{5d1}\u{5d2}"; diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 6c59acd43..d4c1bf788 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -80,6 +80,16 @@ every-line composer ignored the last line's slack and left "twelve" alone on a s 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. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8ea876914dfc0eb6c87bddecf7afa5dfd28dfdd8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:22:04 +0200 Subject: [PATCH 04/10] Text animators: Tracking Amount in pixels, Subtract selectors stop at 0, as After Effects A text animator's Tracking Amount is in pixels in After Effects 2026, not in 1/1000 em like the Character panel's tracking: Amount 30 on every character adds 30 px between neighbours (half before, half after each) at 80 px and at 40 px. And a Subtract range selector over characters the first selector leaves out keeps them at 0 instead of selecting them negatively. With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2-4 by index, Subtract over the last 20 %) puts every glyph where AE does (it ended 88 px short, 1553 vs 1641; now 1641). The "Tracking In" text preset animates 40 -> 0 px (was 400 -> 0 thousandths of an em, about the same at 100 px text); Lottie export writes a style run's tracking (1/1000 em) as pixels for its tracking animator, which Lottie players add in pixels. Co-Authored-By: Claude Opus 5.5 --- crates/engine/presets/text_animators.json | 2 +- crates/lottie/src/text.rs | 4 +-- crates/render/src/text.rs | 8 +++--- crates/render/src/text/tests.rs | 33 +++++++++++++++++++---- crates/text/src/selectors.rs | 7 +++++ docs/fidelity/README.md | 12 +++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -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, diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..e9de3d328 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { 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))); + // An animator's tracking is in pixels; the Character panel's in 1/1000 em. + props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size))); } 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]))); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..302f31162 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -639,10 +639,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { 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]; - let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; - pen += x.track_before * em; + // 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 * em + vadv[gi]; + pen += x.track_after + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..760bd2fff 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -202,9 +202,9 @@ fn tracking_line_anchor_and_character_offset() { animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); p.comp_mut(cid).unwrap().layers.push(l); 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(500.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 × 500/1000 em × 40 px: 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. @@ -231,6 +231,29 @@ fn tracking_line_anchor_and_character_offset() { assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); } +/// After Effects 2026: a text animator's Tracking Amount is in pixels: Amount 30 on every +/// character of an 80 px and of a 40 px text adds 30 px between neighbouring characters (half +/// before, half after each one). +#[test] +fn animator_tracking_is_in_pixels_as_in_after_effects() { + for size in [80.0, 40.0] { + let x_of = |amount: f64| { + let (mut p, cid, comp) = setup(900, 200); + let mut l = text_layer(&mut p, &comp, doc("HHHH", size)); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + let paths = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + paths.iter().map(|g| bounds_of(std::slice::from_ref(g)).x0).collect::>() + }; + let (a, b) = (x_of(0.0), x_of(30.0)); + for i in 1..a.len() { + let gap = (b[i] - b[i - 1]) - (a[i] - a[i - 1]); + assert!((gap - 30.0).abs() < 0.5, "{size} px: neighbour gap grows by {gap}"); + } + } +} + /// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it: /// left text grows right, centred text around its centre, right text to the left (#146). #[test] @@ -244,10 +267,10 @@ 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 × 500/1000 em × 40 px: 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, 500.0))); + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); } @@ -259,7 +282,7 @@ fn tracking_grows_from_each_paragraphs_alignment() { let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); [bounds_of(&a), bounds_of(&b)] }; - let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0))); assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); } diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..0306b7e09 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,10 @@ 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). + 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 +356,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); } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index d4c1bf788..22fbd4330 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -90,6 +90,18 @@ Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_star 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 writes a style run's +tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8b64074ce367b980eab881feb4910aa4ec5a699b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:56:52 +0200 Subject: [PATCH 05/10] Text: per-character 3D rotation turns Z first, as a 3D layer and After Effects With per-character 3D on, After Effects turns each character by Rx * Ry * Rz (Z first), as it turns a 3D layer's X / Y / Z Rotation (case 931, AE 2026: "H" at 400 px with animator X / Y / Z Rotation 40 / 30 / 20). 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/255). The same order fix-orientation uses for layers; texts with one rotation axis are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 4 ++-- crates/render/src/text/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/fidelity/README.md | 8 ++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 302f31162..c16419af8 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -752,9 +752,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // 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) + 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)); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 760bd2fff..98ef3ce14 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -484,6 +484,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); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 22fbd4330..db579e257 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -102,6 +102,14 @@ preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie exp tracking as pixels. 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`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From daedbd5ca1f0e727a54a16f7df2889067b4aebed Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:21 +0200 Subject: [PATCH 06/10] Review: short "d" falls back to the font ascent; tracking unit comment; Lottie hunk out - first_ascent: a "d" shorter than 0.3 x the font ascent (a symbol or icon font) no longer puts box text's first baseline almost at the box top; the font ascent is used, as before the "d" rule (CodeRabbit on #1875). Test: a_short_d_falls_back_to_the_font_ascent. - CharXf::track_before / track_after are pixels, not 1/1000 em (CodeRabbit). - The Lottie export of style-run tracking is back to what it was: the pixel conversion was not checked against a player, so it is left for a separate, verified change. - The Subtract clamp in Mode::combine is intended for every animator property: the selectors combine into one selection per character for the whole animator, as in After Effects; the read-back observed it through Tracking (now said in the code). - Per-character 3D rotation notes that #1876 adds Mat4::rotation_xyz for the same order. Co-Authored-By: Claude Opus 5.5 --- crates/lottie/src/text.rs | 4 ++-- crates/render/src/text.rs | 6 +++++- crates/text/src/layout.rs | 37 +++++++++++++++++++++++++++++++++++- crates/text/src/selectors.rs | 5 +++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index e9de3d328..125835c2b 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { props.insert("s".into(), k(json!([p, p, 100.0]))); } if (s.tracking - base.tracking).abs() > 1e-9 { - // An animator's tracking is in pixels; the Character panel's in 1/1000 em. - props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size))); + // 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]))); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c16419af8..47f5953fa 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (1/1000 em) 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. @@ -752,6 +753,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; + // 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_z(x.rotation) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3ce08b84f..646a0377a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -577,8 +577,20 @@ fn first_ascent(face: FaceId, px: f32) -> Option { 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 { let h = em * px; - (h.is_finite() && h > 0.0 && em < 4.0).then_some(h) + 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 { @@ -1284,6 +1296,29 @@ mod tests { } } + /// 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, 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}"); + } + } + #[test] fn newlines_make_paragraphs_and_leading_adds() { let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index 0306b7e09..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -74,6 +74,11 @@ impl Mode { // 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, From 2fd7d933ee6b6f5c6d89c24168a3daa3d6ece248 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:41 +0200 Subject: [PATCH 07/10] Review: fidelity notes no longer claim the Lottie tracking export change Co-Authored-By: Claude Opus 5.5 --- docs/fidelity/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index db579e257..b2992749d 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -98,8 +98,8 @@ characters at 80 px and at 40 px, half before and half after each), and a Subtra 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 writes a style run's -tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +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 From 2aecd57c28a666c5747896ab78de664c9fbb2eb5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 11:23:49 +0100 Subject: [PATCH 08/10] Text: justified RTL point text follows the resolved direction; only the first line decides the vertical baseline Review (CodeRabbit on #1875): with the paragraph direction unset, a right-to-left paragraph resolved by BidiInfo was still aligned as left-to-right justified point text, so it stayed left instead of flush right. eff_align now takes the resolved direction. The box-text first baseline used the font ascent whenever any run of the text was vertical; it now looks only at the runs of the first line. Two regression tests. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b8e221274..a035bb21b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -654,8 +654,9 @@ fn paragraph( let st = &runs[si].1; first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; - let eff_align = |last: bool| -> Align { - if rtl_para && width.is_none() && para.align == Align::Justify { + // `rtl` is the resolved paragraph direction (an unset direction follows the text). + let eff_align = |last: bool, rtl: 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 @@ -679,7 +680,7 @@ fn paragraph( descent: pm.descent, first: style_first(si), advance: runs[si].1.line_advance(), - align: eff_align(true), + align: eff_align(true, rtl_para), avail: avail_for(true), margins: margins_for(true), underline: None, @@ -861,7 +862,7 @@ fn paragraph( let mut out = Vec::new(); for (li, r) in ranges.into_iter().enumerate() { let last = li + 1 == nlines; - let align = eff_align(last); + let align = eff_align(last, base_rtl); let avail = avail_for(li == 0); let (ce, tw) = trailing_ws(&r); let hang = hang_of(&r, ce); @@ -1084,7 +1085,8 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para let para = para_at(pi); if i == 0 { // Vertical type keeps its first column at the font ascent (not measured in AE). - let vertical = runs.iter().any(|r| r.1.vertical); + 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); @@ -1613,6 +1615,33 @@ mod tests { } } + /// 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:?}"); + } + + /// 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); + } + #[test] fn oversized_layout_requests_are_bounded_without_caching() { let text = "a".repeat(MAX_LAYOUT_BYTES + 1); From ba2023ac945f60bc4729896a252d1b011e8ecf99 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 15:05:42 +0100 Subject: [PATCH 09/10] Text: indents follow the resolved paragraph direction too Review (CodeRabbit on #1875): with the direction unset, Hebrew text resolved to right-to-left took its start indent on the left. The paragraph direction is now resolved once from BidiInfo before the margin and alignment helpers, so both use it. Regression test. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index a035bb21b..533b897c4 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -637,7 +637,10 @@ fn paragraph( para: &ParagraphStyle, width: Option, ) -> Vec { - let rtl_para = para.rtl == Some(true); + let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); + let bidi = BidiInfo::new(text, default_level); + // The resolved paragraph direction: an unset direction follows the text. + let rtl_para = bidi.paragraphs.first().map_or(para.rtl == Some(true), |p| p.level.is_rtl()); let margins_for = |first: bool| -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } @@ -654,9 +657,8 @@ fn paragraph( let st = &runs[si].1; first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; - // `rtl` is the resolved paragraph direction (an unset direction follows the text). - let eff_align = |last: bool, rtl: bool| -> Align { - if rtl && width.is_none() && para.align == Align::Justify { + let eff_align = |last: bool| -> Align { + if rtl_para && 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 @@ -680,16 +682,14 @@ fn paragraph( descent: pm.descent, first: style_first(si), advance: runs[si].1.line_advance(), - align: eff_align(true, rtl_para), + align: eff_align(true), avail: avail_for(true), margins: margins_for(true), underline: None, }]; } - let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); - let bidi = BidiInfo::new(text, default_level); let pinfo = &bidi.paragraphs[0]; - let base_rtl = pinfo.level.is_rtl(); + let base_rtl = rtl_para; // per char: (byte, char, shaped char, face, rtl, form, run) let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text .char_indices() @@ -862,7 +862,7 @@ fn paragraph( let mut out = Vec::new(); for (li, r) in ranges.into_iter().enumerate() { let last = li + 1 == nlines; - let align = eff_align(last, base_rtl); + let align = eff_align(last); let avail = avail_for(li == 0); let (ce, tw) = trailing_ws(&r); let hang = hang_of(&r, ce); @@ -1629,6 +1629,21 @@ mod tests { 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| { + 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] From bb7fda41bb54684e7d341f5781f711ba85ed66b6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 03:40:17 +0700 Subject: [PATCH 10/10] Text tests: drop the tracking test #1822 already landed on main Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text/tests.rs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index d17bfbdb3..4bf459142 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -249,29 +249,6 @@ fn tracking_line_anchor_and_character_offset() { assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); } -/// After Effects 2026: a text animator's Tracking Amount is in pixels: Amount 30 on every -/// character of an 80 px and of a 40 px text adds 30 px between neighbouring characters (half -/// before, half after each one). -#[test] -fn animator_tracking_is_in_pixels_as_in_after_effects() { - for size in [80.0, 40.0] { - let x_of = |amount: f64| { - let (mut p, cid, comp) = setup(900, 200); - let mut l = text_layer(&mut p, &comp, doc("HHHH", size)); - animator(&mut p, &mut l, &["tracking"], &["range"]); - set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); - p.comp_mut(cid).unwrap().layers.push(l); - let paths = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); - paths.iter().map(|g| bounds_of(std::slice::from_ref(g)).x0).collect::>() - }; - let (a, b) = (x_of(0.0), x_of(30.0)); - for i in 1..a.len() { - let gap = (b[i] - b[i - 1]) - (a[i] - a[i - 1]); - assert!((gap - 30.0).abs() < 0.5, "{size} px: neighbour gap grows by {gap}"); - } - } -} - /// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it: /// left text grows right, centred text around its centre, right text to the left (#146). #[test]