Files
effectcraft/crates/render/src/text/tests.rs
T
bflatasticandClaude Opus 5.5 88e2e6470a Text: touching glyphs join without a seam (#415)
Cause: each glyph's fill was rasterised and composited on its own, so
where two glyphs share an edge (the crossbars of "ff") two half-covered
pixels composited to 0.75 coverage instead of 1: a thin light seam,
where After Effects draws the line as one outline.

Fix: consecutive fill passes of the same opaque colour, without Blur,
under Normal Inter-Character Blending are rasterised as one coverage
mask (the glyphs' outlines in layer space, wound the same way so
overlaps add up under NonZero). Characters of other colours, translucent
or blurred ones, strokes and the other blending modes are drawn one by
one as before.

Tests: text::tests::touching_glyphs_join_without_a_seam (two glyphs
sharing an edge mid-pixel: 0.75 before, 1.0 now; opposite windings and
overlaps stay filled; other colours and Multiply still composite one by
one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:17:32 +07:00

529 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, PropGroup};
use effectcraft_text::layout_doc;
use effectcraft_time::{FrameRate, Tick};
use super::*;
fn setup(w: u32, h: u32) -> (Project, ItemId, Comp) {
let mut p = Project::default();
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(4.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn text_layer(p: &mut Project, comp: &Comp, doc: TextDoc) -> Layer {
let mut l = build::layer(p, comp, "Text", LayerSource::Text, (comp.width, comp.height), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc));
l
}
/// Add an animator with `kinds` and selectors (`range`/`wiggly`/`expression`); returns its index.
fn animator(p: &mut Project, l: &mut Layer, kinds: &[&str], sels: &[&str]) -> usize {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let props = kinds.iter().flat_map(|k| build::text_anim_props(&mut ids, k, false)).collect();
let mut g = build::text_animator(&mut ids, "Animator", props);
let sg = g.sub_mut("selectors").unwrap();
for (i, k) in sels.iter().enumerate() {
if i == 0 && *k == "range" {
continue;
}
if i == 0 {
sg.children.clear();
}
sg.children.push(build::text_selector(&mut ids, k, k).unwrap().into());
}
p.next_id = next;
let anims = l.props.group_mut("text/animators").unwrap();
anims.children.push(g.into());
anims.children.len()
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn ctx<'a>(p: &'a Project, cid: ItemId, t: f64) -> EvalCtx<'a> {
let comp = p.comp(cid).unwrap();
EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(t))
}
fn selection(p: &Project, cid: ItemId, t: f64, anim: usize) -> Vec<f64> {
let c = ctx(p, cid, t);
let l = &p.comp(cid).unwrap().layers[0];
let doc = source_text(&c, l).unwrap();
let lay = layout_doc(&doc);
let a = l.props.group(&format!("text/animators/#{anim}")).unwrap();
animator_selection(&c, l, a, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect()
}
fn doc(text: &str, size: f64) -> TextDoc {
TextDoc { text: text.into(), size, ..Default::default() }
}
#[test]
fn range_based_on_words_and_lines() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("AB CD\nEF", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
set(&mut l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(2));
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(34.0));
p.comp_mut(cid).unwrap().layers.push(l);
// Glyphs: A B ␠ C D E F (the newline has no glyph). Word 1 only.
let s = selection(&p, cid, 0.0, a);
assert_eq!(s, vec![1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
// Lines: the first half = line 1.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(3));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
let s = selection(&p, cid, 0.0, a);
assert_eq!(s, vec![1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0], "{s:?}");
// Characters excluding spaces: 6 units, first half = A B C.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(1));
let s = selection(&p, cid, 0.0, a);
assert_eq!(s[0..2], [1.0, 1.0]);
assert_eq!(s[3], 1.0);
assert_eq!(s[4..], [0.0, 0.0, 0.0]);
// Index units: start 1, end 3 characters.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(0));
set(l, "text/animators/#1/selectors/#1/advanced/units", Value::Enum(1));
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(1.0));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(3.0));
let s = selection(&p, cid, 0.0, a);
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
}
#[test]
fn selector_modes_and_randomize() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCDEFGH", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range", "range"]);
let s1 = "text/animators/#1/selectors/#1";
let s2 = "text/animators/#1/selectors/#2";
set(&mut l, &format!("{s1}/end"), Value::Scalar(50.0));
set(&mut l, &format!("{s2}/end"), Value::Scalar(25.0));
set(&mut l, &format!("{s2}/advanced/mode"), Value::Enum(1)); // subtract
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]);
let modes = [
(2, vec![1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
(4, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
(5, vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
];
for (m, want) in modes {
set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s2}/advanced/mode"), Value::Enum(m));
assert_eq!(selection(&p, cid, 0.0, a), want, "mode {m}");
}
// A lone Subtract selector inverts.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
if let Some(Node::Group(g)) = l.props.group_mut("text/animators/#1/selectors").map(|g| &mut g.children[1]) {
g.enabled = false;
}
set(l, &format!("{s1}/advanced/mode"), Value::Enum(1));
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
// Randomize Order: still half selected, but not the first half; seed changes it.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, &format!("{s1}/advanced/mode"), Value::Enum(0));
set(l, &format!("{s1}/advanced/randomize"), Value::Bool(true));
let r1 = selection(&p, cid, 0.0, a);
assert_eq!(r1.iter().sum::<f64>(), 4.0);
assert_ne!(r1, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]);
assert_eq!(r1, selection(&p, cid, 0.0, a));
set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s1}/advanced/randomSeed"), Value::Scalar(5.0));
assert_ne!(selection(&p, cid, 0.0, a), r1);
}
#[test]
fn wiggly_selector_is_deterministic_and_time_dependent() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("WIGGLE", 40.0));
animator(&mut p, &mut l, &["position"], &["range", "wiggly"]);
set(&mut l, "text/animators/#1/properties/position", Value::Vec3([0.0, 50.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let l = &p.comp(cid).unwrap().layers[0];
let a = glyph_paths(&ctx(&p, cid, 0.5), l);
let b = glyph_paths(&ctx(&p, cid, 0.5), l);
let c = glyph_paths(&ctx(&p, cid, 1.3), l);
let ys = |v: &[(BezPath, CharXf)]| v.iter().map(|g| g.1.offset[1]).collect::<Vec<_>>();
assert_eq!(ys(&a), ys(&b));
assert_ne!(ys(&a), ys(&c));
// Within ±50 and not all identical across characters (correlation 50%).
assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9));
assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6));
// The layer cache keys a wiggly text layer by time.
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false, false);
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, false);
assert_ne!(k1, k2);
// Renders identically twice.
let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
let r2 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
assert_eq!(r1.data, r2.data);
}
fn bounds_of(paths: &[(BezPath, CharXf)]) -> kurbo::Rect {
let v: Vec<BezPath> = paths.iter().map(|p| p.0.clone()).collect();
effectcraft_path::bounds(&v).unwrap()
}
#[test]
fn tracking_line_anchor_and_character_offset() {
let (mut p, cid, comp) = setup(600, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0));
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));
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).
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.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/lineAnchor", Value::Scalar(100.0));
let w2 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
assert!((w2.x1 - w0.x1 + 10.0).abs() < 1.0, "{} {}", w0.x1, w2.x1);
// Character Offset 1: "ABCD" draws as "BCDE".
let (mut p, cid, comp) = setup(600, 200);
let mut l = text_layer(&mut p, &comp, doc("AZ9", 40.0));
animator(&mut p, &mut l, &["characterOffset"], &["range"]);
set(&mut l, "text/animators/#1/properties/characterOffset", Value::Scalar(1.0));
set(&mut l, "text/animators/#1/properties/characterAlignment", Value::Enum(3));
p.comp_mut(cid).unwrap().layers.push(l);
let got = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
let (mut q, qid, qc) = setup(600, 200);
let l2 = text_layer(&mut q, &qc, doc("BA0", 40.0));
q.comp_mut(qid).unwrap().layers.push(l2);
let want = bounds_of(&glyph_paths(&ctx(&q, qid, 0.0), &q.comp(qid).unwrap().layers[0]));
assert!((got.width() - want.width()).abs() < 0.01 && (got.height() - want.height()).abs() < 0.01, "{got:?} {want:?}");
assert_eq!(substitute('z', &CharXf { char_offset: 2.0, ..Default::default() }), Some('b'));
assert_eq!(substitute('-', &CharXf { char_offset: 2.0, ..Default::default() }), None);
assert_eq!(substitute('-', &CharXf { char_offset: 2.0, char_range: 1, ..Default::default() }), Some('/'));
assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B'));
}
/// 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]
fn tracking_grows_from_each_paragraphs_alignment() {
use effectcraft_keyframe::{Justify, ParaStyle};
let tracked = |d: TextDoc, amount: f64| {
let (mut p, cid, comp) = setup(600, 300);
let mut l = text_layer(&mut p, &comp, d);
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);
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.
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 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]);
}
// Each paragraph by its own alignment: a left paragraph above a right-aligned one.
let mut d = doc("ABCD\nABCD", 40.0);
let left = d.base_para();
d.set_paras(vec![left.clone(), ParaStyle { justify: Justify::Right, ..left }]);
let lines = |paths: Vec<(BezPath, CharXf)>| -> [kurbo::Rect; 2] {
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)));
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);
}
#[test]
fn anchor_grouping_line_rotates_around_line_centre() {
let (mut p, cid, comp) = setup(600, 300);
let mut l = text_layer(&mut p, &comp, doc("HHHH", 40.0));
animator(&mut p, &mut l, &["rotation"], &["range"]);
set(&mut l, "text/animators/#1/properties/rotation", Value::Scalar(90.0));
p.comp_mut(cid).unwrap().layers.push(l);
let per_char = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let cx: Vec<f64> = per_char.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect();
assert!(cx.windows(2).all(|w| w[1] > w[0] + 10.0), "characters rotate in place: {cx:?}");
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/anchorGrouping", Value::Enum(2));
let line = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let cx: Vec<f64> = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect();
assert!(cx.windows(2).all(|w| (w[1] - w[0]).abs() < 1.0), "the line turns as one: {cx:?}");
let cy: Vec<f64> = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect();
assert!(cy.windows(2).all(|w| w[1] > w[0] + 10.0), "{cy:?}");
}
fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() };
let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]);
p.next_id = next;
let masks = l.props.group_mut("masks").unwrap();
masks.children.push(g.into());
}
#[test]
fn path_text_follows_a_mask() {
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
// A vertical path going down at x = 100.
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
set(&mut l, "text/pathOptions/path", Value::Enum(1));
set(&mut l, "text/pathOptions/firstMargin", Value::Scalar(40.0));
p.comp_mut(cid).unwrap().layers.push(l);
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
assert_eq!(g.len(), 4);
let bs: Vec<kurbo::Rect> = g.iter().map(|x| effectcraft_path::bounds(std::slice::from_ref(&x.0)).unwrap()).collect();
// Perpendicular: glyphs stand on the path, rotated 90° (to the right of x = 100), in order down.
for b in &bs {
assert!(b.x0 > 99.0 && b.x1 < 100.0 + 30.0, "{b:?}");
assert!(b.height() < 30.0);
}
assert!(bs.windows(2).all(|w| w[1].center().y > w[0].center().y));
assert!((bs[0].y0 - 60.0).abs() < 4.0, "first margin 40 from the start at y = 20: {:?}", bs[0]);
// Reverse Path: starts from the bottom.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/reversePath", Value::Bool(true));
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap();
assert!(b0.center().y > 300.0 && b0.x1 < 101.0, "{b0:?}");
// Not perpendicular: glyphs stay upright.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/perpendicular", Value::Bool(false));
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap();
assert!(b0.height() > b0.width(), "{b0:?}");
// Force alignment spreads to the margins.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/pathOptions/reversePath", Value::Bool(false));
set(l, "text/pathOptions/forceAlignment", Value::Bool(true));
set(l, "text/pathOptions/firstMargin", Value::Scalar(0.0));
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
let first = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap().center().y;
let last = effectcraft_path::bounds(std::slice::from_ref(&g[3].0)).unwrap().center().y;
assert!(first < 40.0 && last > 360.0, "{first} {last}");
}
fn alpha_bbox(img: &crate::Image) -> Option<[i64; 4]> {
let mut b: Option<[i64; 4]> = None;
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
if img.get(x, y)[3] > 0.2 {
b = Some(match b {
None => [x, y, x, y],
Some(r) => [r[0].min(x), r[1].min(y), r[2].max(x), r[3].max(y)],
});
}
}
}
b
}
#[test]
fn render_key_pixels_fill_hue_blur_and_fill_stroke_order() {
let (mut p, cid, comp) = setup(200, 100);
let mut l = text_layer(&mut p, &comp, TextDoc { text: "I".into(), size: 90.0, fill: [1.0, 0.0, 0.0, 1.0], style: "Bold".into(), ..Default::default() });
set(&mut l, "transform/position", Value::Vec3([90.0, 80.0, 0.0]));
animator(&mut p, &mut l, &["fillHue"], &["range"]);
set(&mut l, "text/animators/#1/properties/fillHue", Value::Scalar(120.0));
p.comp_mut(cid).unwrap().layers.push(l);
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
let b = alpha_bbox(&img).unwrap();
let c = img.get((b[0] + b[2]) / 2, (b[1] + b[3]) / 2);
assert!(c[1] > 0.9 && c[0] < 0.1, "hue +120° turns red green: {c:?}");
// Fill Opacity 0 hides the fill.
let mut l = p.comp(cid).unwrap().layers[0].clone();
animator(&mut p, &mut l, &["fillOpacity"], &["range"]);
set(&mut l, "text/animators/#2/properties/fillOpacity", Value::Scalar(0.0));
p.comp_mut(cid).unwrap().layers[0] = l;
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(alpha_bbox(&img).is_none());
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.pop();
// Blur spreads the glyph.
let sharp = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
let mut l = p.comp(cid).unwrap().layers[0].clone();
animator(&mut p, &mut l, &["blur"], &["range"]);
set(&mut l, "text/animators/#2/properties/blur", Value::Vec2([20.0, 0.0]));
p.comp_mut(cid).unwrap().layers[0] = l;
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
let soft = img.get(sharp[0] - 4, (sharp[1] + sharp[3]) / 2);
assert!(soft[3] > 0.05, "blur reaches outside the sharp edge: {soft:?}");
let core = img.get((sharp[0] + sharp[2]) / 2, (sharp[1] + sharp[3]) / 2);
assert!(core[3] < 0.99, "and softens the core: {core:?}");
}
#[test]
fn fill_stroke_modes_order_layers() {
// Two overlapping characters ("AA" with negative tracking), stroke on.
let (mut p, cid, comp) = setup(300, 150);
let d = TextDoc {
text: "OO".into(),
size: 100.0,
tracking: -400.0,
fill: [1.0, 0.0, 0.0, 1.0],
stroke: [0.0, 0.0, 1.0, 1.0],
apply_stroke: true,
stroke_width: 6.0,
stroke_over_fill: false,
..Default::default()
};
let mut l = text_layer(&mut p, &comp, d);
set(&mut l, "transform/position", Value::Vec3([60.0, 110.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let geom = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap();
assert_eq!(geom.glyphs.len(), 2);
let order = |fs: u32| {
let mut g = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap();
g.fill_stroke = fs;
passes(&g)
};
assert_eq!(order(1), vec![(0, true), (1, true), (0, false), (1, false)]);
assert_eq!(order(2), vec![(0, false), (1, false), (0, true), (1, true)]);
assert_eq!(order(0), vec![(0, true), (0, false), (1, true), (1, false)]);
// Inter-character blending: Multiply darkens where the characters overlap.
let normal = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/interCharBlend", Value::Enum(2));
let multiply = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
assert_ne!(normal.data, multiply.data);
}
#[test]
fn per_character_3d_projects_characters() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, TextDoc { text: "MM".into(), size: 80.0, justify: effectcraft_keyframe::Justify::Center, ..Default::default() });
set(&mut l, "transform/position", Value::Vec3([200.0, 130.0, 0.0]));
l.switches.three_d = true;
animator(&mut p, &mut l, &["position", "rotation"], &["range"]);
p.comp_mut(cid).unwrap().layers.push(l);
let flat = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
// Per-character 3D off: Z position is ignored.
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/position", Value::Vec3([0.0, 0.0, -300.0]));
let off = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
assert_eq!(flat, off);
// On: characters come towards the camera and get bigger.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let t = l.props.sub_mut("text").unwrap();
t.get_mut("perChar3d").unwrap().value = Value::Bool(true);
let near = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
assert!(near[2] - near[0] > (flat[2] - flat[0]) + 20, "{flat:?} {near:?}");
assert_eq!(per_char_planes(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0], 1.0).len(), 2);
// Y rotation 90° turns every character edge-on: nothing visible.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/properties/position", Value::Vec3([0.0; 3]));
let mut next = p.next_id;
let ry = build::text_anim_props(&mut Ids(&mut next), "rotation", true).remove(1);
p.next_id = next;
let props: &mut PropGroup = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap();
props.children.push(ry.into());
props.get_mut("rotationY").unwrap().value = Value::Scalar(90.0);
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
let covered = |img: &crate::Image| img.data.iter().filter(|p| p[3] > 0.2).count();
let vis = covered(&img);
let flat_px = {
let mut q = p.clone();
q.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap().get_mut("rotationY").unwrap().value = Value::Scalar(0.0);
covered(&crate::render_frame(&q, cid, Tick::ZERO, 1.0))
};
assert!(vis * 10 < flat_px, "edge-on characters are thin slivers: {vis} of {flat_px}");
// 45°: narrower than flat but visible.
let props = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap();
props.get_mut("rotationY").unwrap().value = Value::Scalar(60.0);
let b = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}");
}
#[test]
fn keyframed_selector_animates() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0));
animator(&mut p, &mut l, &["opacity"], &["range"]);
l.props.prop_mut("text/animators/#1/selectors/#1/start").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, 1), vec![1.0; 4]);
assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]);
}
#[test]
fn caret_follows_animators_and_path_text() {
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["position"], &["range"]);
set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
assert_eq!(maps.len(), 5);
for (ci, m) in maps.iter().enumerate() {
let (top, _) = lay.caret(ci);
let q = m.apply(vec2(top.x, top.y));
assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}");
}
// On a vertical path (x = 100, running down) the caret turns with the characters and sits
// on the path.
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
set(&mut l, "text/pathOptions/path", Value::Enum(1));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
let (t, b) = lay.caret(2);
let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y)));
assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}");
assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}");
let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::<Vec<_>>();
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
}
/// Glyphs that touch or overlap are drawn as one outline, as After Effects draws a line of text:
/// no light seam where their anti-aliased edges meet (the crossbars of "ff", #415).
#[test]
fn touching_glyphs_join_without_a_seam() {
use kurbo::Shape;
let glyph = |x0: f64, x1: f64, fill: [f32; 4], reversed: bool| {
let path = kurbo::Rect::new(x0, 4.0, x1, 16.0).to_path(0.1);
PlacedGlyph {
local: if reversed { path.reverse_subpaths() } else { path },
m: Mat4::IDENTITY,
xf: CharXf::default(),
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
apply_fill: true,
char_index: 0,
rest: [0.0; 2],
}
};
let draw = |glyphs: Vec<PlacedGlyph>, blend: BlendMode| {
let geom = TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend };
let mut img = crate::Image::new(24, 20);
draw_glyphs(&geom, &mut img, &Mat3::IDENTITY, 1.0, None);
img
};
let (white, red) = ([1.0; 4], [1.0, 0.0, 0.0, 1.0]);
// The shared edge runs through the middle of pixel column 10.
let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Normal).get(10, 10);
assert!(seam[3] > 0.999 && seam[0] > 0.999, "touching glyphs leave no seam: {seam:?}");
// Outlines wound the other way (another font, a mirrored character) still add up.
let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, true)], BlendMode::Normal).get(10, 10);
assert!(seam[3] > 0.999, "opposite windings join too: {seam:?}");
let overlap = draw(vec![glyph(4.0, 12.0, white, false), glyph(8.0, 17.0, white, true)], BlendMode::Normal).get(10, 10);
assert!(overlap[3] > 0.999, "and their overlap stays filled: {overlap:?}");
// Characters of other colours, or blended with each other, are still drawn one by one.
let two = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, red, false)], BlendMode::Normal).get(10, 10);
assert!((two[3] - 0.75).abs() < 0.02, "{two:?}");
let multiply = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Multiply).get(10, 10);
assert!(multiply[3] < 0.9, "{multiply:?}");
}