mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user