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/render/src/text.rs b/crates/render/src/text.rs index 132da9bf6..c25b1da8d 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 (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 { 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 { }; // 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)); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 43f53dce9..4bf459142 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -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); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 179478517..4c0b5a436 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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 { + 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 { + 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, glyphs: Vec, @@ -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, 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, first: bool) -> Option { - let (l, r) = margins_for(para, first); +fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option, first: bool) -> Option { + 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, last: bool) -> Align { - if (last || width.is_none()) && para.align == Align::Justify { +fn align_for(para: &ParagraphStyle, rtl: bool, width: Option, 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, 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, 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> { + let last_counts = self.para.align == Align::Justify && self.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]; @@ -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, 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, glyphs: &[Glyph]) -> (f32, f32, f32) { - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + fn line_metrics(&self, r: &Range, 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 = 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, 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; diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 9079a1225..5792c1c16 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -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, 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 { 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| { + 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); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7ecc06..8cc8e3264 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(); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -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); } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index fc780016b..0398118c5 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -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