effectcraft/crates/render/src/text.rs

912 lines
37 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.

//! Text layers: Source Text layout → text animators (Range / Wiggly / Expression selectors,
//! combined by mode) → per-character transforms and paint → outlines, optionally placed on a mask
//! path (Path Options) and drawn with More Options (anchor point grouping, fill & stroke order,
//! inter-character blending). Per-character 3D characters are handed to the 3D compositor as
//! separate planes (see [`per_char_planes`]).
use effectcraft_color::BlendMode;
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::Image;
use effectcraft_text::path_text::{self, PathMeasure};
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
use crate::eval::EvalCtx;
/// Evaluated per-character animation state (sums of every animator, weighted by selection).
#[derive(Clone, Copy, Debug)]
pub struct CharXf {
pub offset: [f64; 3],
pub anchor: [f64; 3],
pub scale: [f64; 3],
/// Z rotation (2D rotation).
pub rotation: f64,
pub rotation_x: f64,
pub rotation_y: f64,
pub skew: f64,
pub skew_axis: f64,
pub opacity: f64,
pub fill: Option<[f32; 4]>,
pub fill_k: f32,
/// Hue (degrees), saturation and brightness (percent) offsets.
pub fill_hsb: [f64; 3],
pub fill_opacity: f64,
pub stroke: Option<[f32; 4]>,
pub stroke_k: f32,
pub stroke_hsb: [f64; 3],
pub stroke_opacity: f64,
pub stroke_width: f64,
/// Tracking (1/1000 em) added before / after the character.
pub track_before: f64,
pub track_after: f64,
/// Line Anchor (percent) and the selection weight it was set with.
pub line_anchor: f64,
pub line_anchor_k: f64,
pub line_spacing: [f64; 2],
pub char_offset: f64,
/// Character Value (code point) and its selection.
pub char_value: Option<(f64, f64)>,
pub char_range: u32,
pub char_align: u32,
pub blur: [f64; 2],
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
pub axes: [([u8; 4], f64); 4],
pub n_axes: u8,
}
impl CharXf {
/// The variable-font axis offsets that move the outline.
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
self.axes[..self.n_axes as usize]
.iter()
.filter(|(_, d)| d.abs() > 1e-6)
.map(|(t, d)| (String::from_utf8_lossy(t).trim_end().to_string(), *d as f32))
.collect()
}
fn add_axis(&mut self, tag: [u8; 4], d: f64) {
if let Some(a) = self.axes[..self.n_axes as usize].iter_mut().find(|a| a.0 == tag) {
a.1 += d;
} else if (self.n_axes as usize) < self.axes.len() {
self.axes[self.n_axes as usize] = (tag, d);
self.n_axes += 1;
}
}
}
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
pub const AXIS_PREFIX: &str = "axis_";
impl Default for CharXf {
fn default() -> Self {
CharXf {
offset: [0.0; 3],
anchor: [0.0; 3],
scale: [100.0; 3],
rotation: 0.0,
rotation_x: 0.0,
rotation_y: 0.0,
skew: 0.0,
skew_axis: 0.0,
opacity: 100.0,
fill: None,
fill_k: 0.0,
fill_hsb: [0.0; 3],
fill_opacity: 100.0,
stroke: None,
stroke_k: 0.0,
stroke_hsb: [0.0; 3],
stroke_opacity: 100.0,
stroke_width: 0.0,
track_before: 0.0,
track_after: 0.0,
line_anchor: 0.0,
line_anchor_k: 0.0,
line_spacing: [0.0; 2],
char_offset: 0.0,
char_value: None,
char_range: 0,
char_align: 1,
blur: [0.0; 2],
axes: [([0; 4], 0.0); 4],
n_axes: 0,
}
}
}
/// The unit a glyph belongs to for a selector's Based On, and the unit count.
fn unit_of(based: BasedOn, g: &CharGlyph, lay: &TextLayout) -> (usize, usize) {
match based {
BasedOn::Characters => (g.char_index, lay.chars),
BasedOn::CharactersExcludingSpaces => (g.char_index_no_space.min(lay.chars_no_space.saturating_sub(1)), lay.chars_no_space),
BasedOn::Words => (g.word_index, lay.words),
BasedOn::Lines => (g.line_index, lay.lines),
}
}
fn unit_count(based: BasedOn, lay: &TextLayout) -> usize {
match based {
BasedOn::Characters => lay.chars,
BasedOn::CharactersExcludingSpaces => lay.chars_no_space,
BasedOn::Words => lay.words,
BasedOn::Lines => lay.lines,
}
}
fn layer_seed(layer: &Layer, seed: f64) -> u64 {
(seed as i64 as u64) ^ layer.id.0.wrapping_mul(0x9E37_79B9)
}
/// Range Selector parameters at the context time (fractions of `n` units).
fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Range {
let adv = sel.sub("advanced");
let af = |m: &str, d: f64| adv.map(|a| ctx.f(layer, a, m, d)).unwrap_or(d);
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let div = if units_index { n.max(1) as f64 } else { 100.0 };
Range {
start: ctx.f(layer, sel, "start", 0.0) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div,
offset: ctx.f(layer, sel, "offset", 0.0) / div,
amount: af("amount", 100.0) / 100.0,
shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)),
smoothness: af("smoothness", 100.0) / 100.0,
ease_high: af("easeHigh", 0.0) / 100.0,
ease_low: af("easeLow", 0.0) / 100.0,
}
}
/// Selection of every glyph by an animator's selectors, per dimension.
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
if sels.is_empty() {
// No selectors: every character is fully affected.
return vec![[1.0; 3]; lay.glyphs.len()];
}
let mut acc: Vec<[f64; 3]> = vec![[0.0; 3]; lay.glyphs.len()];
let lt = layer.layer_time(ctx.time).seconds();
for (si, sel) in sels.iter().enumerate() {
let (mode, based) = match sel.match_id.as_str() {
"rangeSelector" => {
let adv = sel.sub("advanced");
(adv.map(|a| ctx.e(layer, a, "mode")).unwrap_or(0), adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0))
}
"wigglySelector" => (ctx.e(layer, sel, "mode"), ctx.e(layer, sel, "basedOn")),
_ => (0, ctx.e(layer, sel, "basedOn")),
};
let mode = Mode::from_index(mode);
let based = BasedOn::from_index(based);
let n = unit_count(based, lay);
let values: Box<dyn Fn(usize, usize, [f64; 3]) -> [f64; 3]> = match sel.match_id.as_str() {
"rangeSelector" => {
let r = range_params(ctx, layer, sel, n);
let adv = sel.sub("advanced");
let perm =
adv.filter(|a| ctx.b(layer, a, "randomize")).map(|a| selectors::random_order(n, layer_seed(layer, ctx.f(layer, a, "randomSeed", 0.0))));
Box::new(move |i, n, _| {
let i = perm.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(i);
[selectors::range_value(&r, i, n); 3]
})
}
"wigglySelector" => {
let w = Wiggly {
max: ctx.f(layer, sel, "maxAmount", 100.0) / 100.0,
min: ctx.f(layer, sel, "minAmount", -100.0) / 100.0,
wiggles_per_second: ctx.f(layer, sel, "wigglesPerSecond", 2.0),
correlation: ctx.f(layer, sel, "correlation", 50.0) / 100.0,
temporal_phase: ctx.f(layer, sel, "temporalPhase", 0.0),
spatial_phase: ctx.f(layer, sel, "spatialPhase", 0.0),
lock_dimensions: ctx.b(layer, sel, "lockDimensions"),
seed: layer_seed(layer, ctx.f(layer, sel, "randomSeed", 0.0)),
};
Box::new(move |i, _, _| [0, 1, 2].map(|d| selectors::wiggly_value(&w, lt, i, d)))
}
"expressionSelector" => {
let Some(prop) = sel.get("amount") else { continue };
let stat = ctx.v3(layer, sel, "amount", [100.0; 3]);
Box::new(move |i, n, prev| {
let pv = prev.map(|x| x * 100.0);
// Without an expression engine: the static amount scales the selection.
let fallback = [0, 1, 2].map(|d| stat[d] * prev[d]);
let r = match ctx.expr.filter(|_| prop.has_expression()) {
Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(fallback),
None => fallback,
};
r.map(|x| (x / 100.0).clamp(-1.0, 1.0))
})
}
_ => continue,
};
// The Expression Selector's result replaces the selection (it reads it as selectorValue).
let replace = sel.match_id == "expressionSelector";
let mut memo: std::collections::HashMap<(usize, [u64; 3]), [f64; 3]> = std::collections::HashMap::new();
for (gi, g) in lay.glyphs.iter().enumerate() {
let (i, n) = unit_of(based, g, lay);
let prev = match si {
0 if replace => [1.0; 3],
0 => [mode.initial(); 3],
_ => acc[gi],
};
let key = (i, prev.map(f64::to_bits));
let v = *memo.entry(key).or_insert_with(|| values(i, n, prev));
acc[gi] = if replace { v } else { [0, 1, 2].map(|d| mode.combine(prev[d], v[d])) };
}
}
acc
}
/// Evaluate animators for every glyph.
pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &TextLayout) -> Vec<CharXf> {
let mut out = vec![CharXf::default(); lay.glyphs.len()];
let Some(anims) = text.sub("animators") else { return out };
for anim in anims.groups().filter(|g| g.enabled) {
let Some(props) = anim.sub("properties") else { continue };
let sel = animator_selection(ctx, layer, anim, lay);
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p).components());
let get1 = |m: &str| props.get(m).map(|p| ctx.value(layer, p).as_f64());
let c3 = |v: Vec<f64>, d: f64| [v.first().copied().unwrap_or(d), v.get(1).copied().unwrap_or(d), v.get(2).copied().unwrap_or(d)];
let pos = get("position").map(|v| c3(v, 0.0));
let anchor = get("anchor").map(|v| c3(v, 0.0));
let scale = get("scale").map(|v| c3(v, 100.0));
let rot = get1("rotation");
let rx = get1("rotationX");
let ry = get1("rotationY");
let skew = get1("skew");
let skew_axis = get1("skewAxis");
let op = get1("opacity");
let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color());
let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color());
let fill_hsb = [get1("fillHue"), get1("fillSaturation"), get1("fillBrightness")];
let stroke_hsb = [get1("strokeHue"), get1("strokeSaturation"), get1("strokeBrightness")];
let fill_op = get1("fillOpacity");
let stroke_op = get1("strokeOpacity");
let sw = get1("strokeWidth");
let tracking = get1("tracking");
let tracking_type = props.get("trackingType").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(0);
let line_anchor = get1("lineAnchor");
let line_spacing = props.get("lineSpacing").map(|p| ctx.value(layer, p).as_vec2());
let char_offset = get1("characterOffset");
let char_value = get1("characterValue");
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
let axes: Vec<([u8; 4], f64)> = props
.props()
.filter_map(|p| {
let t = p.match_id.strip_prefix(AXIS_PREFIX)?;
let mut tag = [b' '; 4];
for (i, c) in t.bytes().take(4).enumerate() {
tag[i] = c;
}
Some((tag, ctx.value(layer, p).as_f64()))
})
.collect();
for (gi, k3) in sel.iter().enumerate() {
let k = k3[0];
if k3.iter().all(|x| *x == 0.0) {
continue;
}
let c = &mut out[gi];
for d in 0..3 {
if let Some(p) = pos {
c.offset[d] += p[d] * k3[d];
}
if let Some(a) = anchor {
c.anchor[d] += a[d] * k3[d];
}
if let Some(s) = scale {
c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d];
}
}
if let Some(r) = rot {
c.rotation += r * k;
}
if let Some(r) = rx {
c.rotation_x += r * k;
}
if let Some(r) = ry {
c.rotation_y += r * k;
}
if let Some(s) = skew {
c.skew += s * k;
}
if let Some(a) = skew_axis {
c.skew_axis = a;
}
if let Some(o) = op {
c.opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(f) = fill {
c.fill = Some(f);
c.fill_k = (c.fill_k + k as f32).clamp(0.0, 1.0);
}
if let Some(f) = stroke {
c.stroke = Some(f);
c.stroke_k = (c.stroke_k + k as f32).clamp(0.0, 1.0);
}
for d in 0..3 {
if let Some(v) = fill_hsb[d] {
c.fill_hsb[d] += v * k;
}
if let Some(v) = stroke_hsb[d] {
c.stroke_hsb[d] += v * k;
}
}
if let Some(o) = fill_op {
c.fill_opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(o) = stroke_op {
c.stroke_opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(w) = sw {
c.stroke_width += w * k;
}
if let Some(t) = tracking {
let t = t * k;
match tracking_type {
1 => c.track_before += t,
2 => c.track_after += t,
_ => {
c.track_before += t * 0.5;
c.track_after += t * 0.5;
}
}
}
if let Some(a) = line_anchor
&& k.abs() >= c.line_anchor_k
{
c.line_anchor = a;
c.line_anchor_k = k.abs();
}
if let Some(l) = line_spacing {
c.line_spacing[0] += l[0] * k;
c.line_spacing[1] += l[1] * k;
}
if let Some(o) = char_offset {
c.char_offset += o * k;
}
if let Some(v) = char_value {
c.char_value = Some((v, k));
}
if let Some(r) = char_range {
c.char_range = r;
}
if let Some(a) = char_align {
c.char_align = a;
}
if let Some(b) = blur {
c.blur[0] += (b[0] * k3[0]).abs();
c.blur[1] += (b[1] * k3[1]).abs();
}
for (tag, v) in &axes {
c.add_axis(*tag, v * k);
}
}
}
out
}
/// Character Offset / Character Value substitution (None = unchanged).
pub fn substitute(ch: char, x: &CharXf) -> Option<char> {
if ch.is_whitespace() {
return None;
}
let mut code = ch as u32 as f64;
if let Some((v, k)) = x.char_value {
code += (v - code) * k;
}
let off = x.char_offset.round() as i64;
let base = code.round().max(0.0) as u32;
let out = if x.char_range == 1 {
char::from_u32((base as i64 + off).max(0) as u32)
} else {
let c = char::from_u32(base)?;
let wrap = |lo: char, n: i64| char::from_u32((lo as i64 + ((c as i64 - lo as i64 + off).rem_euclid(n))) as u32);
if c.is_ascii_lowercase() {
wrap('a', 26)
} else if c.is_ascii_uppercase() {
wrap('A', 26)
} else if c.is_ascii_digit() {
wrap('0', 10)
} else {
Some(c)
}
}?;
(out != ch).then_some(out)
}
/// The Source Text value at the current time.
pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
let text = layer.props.sub("text")?;
match ctx.group_value(layer, text, "sourceText")? {
Value::Text(t) => Some(*t),
Value::Str(s) => Some(TextDoc { text: s, ..Default::default() }),
_ => None,
}
}
/// Per-character 3D is on for this layer (and the layer is 3D).
pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d"))
}
/// One drawn character.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
/// Outline in character space (the anchor pivot at the origin).
pub local: BezPath,
/// Character space → layer space.
pub m: Mat4,
pub xf: CharXf,
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
/// The character's style fills it (Character panel fill on).
pub apply_fill: bool,
/// Index of the character in the text.
pub char_index: usize,
/// Where the pivot sits in the unanimated layout (layer space): `m · (p − rest)` carries a
/// point of the static layout to where the animation puts this character.
pub rest: [f64; 2],
}
impl PlacedGlyph {
/// Character space → layer space, flattened to 2D (ignores Z).
pub fn m2(&self) -> Mat3 {
let m = &self.m.0;
Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]])
}
/// Outline in layer space (2D).
pub fn path(&self) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
}
}
/// Laid-out, animated text of a layer at the context time.
pub struct TextGeom {
pub doc: TextDoc,
pub glyphs: Vec<PlacedGlyph>,
/// Fill & Stroke: 0 per character palette, 1 all fills over all strokes, 2 all strokes over
/// all fills.
pub fill_stroke: u32,
pub blend: BlendMode,
}
fn adjust_color(base: [f32; 4], rgb: Option<[f32; 4]>, k: f32, hsb: [f64; 3], opacity: f64) -> [f32; 4] {
let mut c = match rgb {
Some(f) => [base[0] + (f[0] - base[0]) * k, base[1] + (f[1] - base[1]) * k, base[2] + (f[2] - base[2]) * k, base[3] + (f[3] - base[3]) * k],
None => base,
};
if hsb != [0.0; 3] {
let (h, s, v) = effectcraft_color::rgb_to_hsv(c[0], c[1], c[2]);
let h = h + (hsb[0] / 360.0) as f32;
let s = (s + (hsb[1] / 100.0) as f32).clamp(0.0, 1.0);
let v = (v + (hsb[2] / 100.0) as f32).clamp(0.0, 1.0);
let (r, g, b) = effectcraft_color::hsv_to_rgb(h, s, v);
c = [r, g, b, c[3]];
}
c[3] *= (opacity / 100.0).clamp(0.0, 1.0) as f32;
c
}
/// The mask path chosen in Path Options (layer space), measured.
fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeasure, bool, bool, f64, f64)> {
let po = text.sub("pathOptions")?;
let k = ctx.e(layer, po, "path") as usize;
if k == 0 {
return None;
}
let mask = layer.masks()?.groups().nth(k - 1)?;
let Some(Value::Path(sp)) = ctx.group_value(layer, mask, "path") else { return None };
let bp = effectcraft_path::to_kurbo(&sp);
let pm = PathMeasure::new(&bp, ctx.b(layer, po, "reversePath"));
if pm.is_empty() {
return None;
}
let perpendicular = po.get("perpendicular").map(|p| ctx.value(layer, p).as_bool()).unwrap_or(true);
Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0)))
}
/// For every caret position `0..=chars` of the layer's text, the map from the static layout
/// (layer space, what [`TextLayout::caret`] returns) to where animators and Path Options put
/// the characters: the caret follows the character before it (or the first one).
pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec<Mat3> {
let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] };
let mut by_char: Vec<Option<Mat3>> = vec![None; chars + 1];
for g in &geom.glyphs {
if let Some(slot) = by_char.get_mut(g.char_index) {
*slot = Some(g.m2() * Mat3::translate(effectcraft_geom::vec2(-g.rest[0], -g.rest[1])));
}
}
let first = by_char.iter().flatten().next().copied().unwrap_or(Mat3::IDENTITY);
let mut out = Vec::with_capacity(chars + 1);
let mut last = first;
for ci in 0..=chars {
// Caret ci sits after character ci − 1.
if let Some(Some(m)) = ci.checked_sub(1).map(|p| by_char[p]) {
last = m;
}
out.push(if ci == 0 { by_char[0].unwrap_or(first) } else { last });
}
out
}
/// Lay out and animate a text layer.
pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let doc = source_text(ctx, layer)?;
let text = layer.props.sub("text")?;
let lay = layout_doc(&doc);
let xfs = char_transforms(ctx, layer, text, &lay);
let three = per_char_3d(ctx, layer);
let more = text.sub("moreOptions");
let grouping = more.map(|m| ctx.e(layer, m, "anchorGrouping")).unwrap_or(0);
let galign = more.map(|m| ctx.v2(layer, m, "groupingAlignment", [0.0; 2])).unwrap_or([0.0; 2]);
let fill_stroke = more.map(|m| ctx.e(layer, m, "fillStroke")).unwrap_or(0);
let blend_idx = more.map(|m| ctx.e(layer, m, "interCharBlend")).unwrap_or(0) as usize;
let blend = effectcraft_project::build::INTER_CHAR_BLEND_MODES.get(blend_idx).and_then(|n| BlendMode::from_name(n)).unwrap_or_default();
// Character substitutions (Character Offset / Value). Adjust Kerning re-lays out the text.
let mut subs: Vec<Option<char>> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect();
let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3);
let (lay, xfs) = if relayout {
let mut chars: Vec<char> = doc.text.chars().collect();
for (g, s) in lay.glyphs.iter().zip(&subs) {
if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) {
*slot = *c;
}
}
// Same character count: the style runs still line up.
let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() };
let l2 = layout_doc(&d2);
let by_char: std::collections::HashMap<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
let x2: Vec<CharXf> = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect();
subs = vec![None; l2.glyphs.len()];
(l2, x2)
} else {
(lay, xfs)
};
// Variable Font Axes: the advance at the animated design-space position (later characters
// on the line move with it).
let n = lay.glyphs.len();
let vadv: Vec<f64> = (0..n)
.map(|gi| match (subs[gi], xfs[gi].axis_deltas()) {
(None, d) if !d.is_empty() => effectcraft_text::variable::char_advance_delta(&lay.glyphs[gi], &d).unwrap_or(0.0),
_ => 0.0,
})
.collect();
let advance = |gi: usize| lay.glyphs[gi].advance + vadv[gi];
// Tracking (before / after each character) and Line Anchor, per line.
let mut shift = vec![0.0f64; n];
let mut li_start = 0;
while li_start < n {
let line = lay.glyphs[li_start].line_index;
let mut li_end = li_start;
while li_end < n && lay.glyphs[li_end].line_index == line {
li_end += 1;
}
let mut pen = 0.0;
let (mut la, mut la_k) = (0.0, 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;
shift[gi] = pen;
pen += x.track_after * em + vadv[gi];
if x.line_anchor_k > la_k {
la = x.line_anchor;
la_k = x.line_anchor_k;
}
}
let back = pen * la / 100.0;
for s in &mut shift[li_start..li_end] {
*s -= back;
}
li_start = li_end;
}
// Glyph origins after tracking / line spacing, and pivots by anchor point grouping.
let origin = |gi: usize| -> [f64; 2] {
let g = &lay.glyphs[gi];
let x = &xfs[gi];
[g.origin.x + shift[gi] + x.line_spacing[0] * g.line_index as f64, g.origin.y + x.line_spacing[1] * g.line_index as f64]
};
let group_key = |g: &CharGlyph| -> usize {
match grouping {
1 => g.word_index * 4096 + g.line_index,
2 => g.line_index,
3 => 0,
_ => usize::MAX,
}
};
let mut extents: std::collections::HashMap<usize, [f64; 4]> = std::collections::HashMap::new();
if grouping != 0 {
for gi in 0..n {
let g = &lay.glyphs[gi];
if g.is_space && grouping == 1 {
continue;
}
let o = origin(gi);
let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]);
e[0] = e[0].min(o[0]);
e[2] = e[2].max(o[0] + advance(gi));
e[1] = e[1].min(o[1]);
e[3] = e[3].max(o[1]);
}
}
// Path Options.
let on_path = text_path(ctx, layer, text);
let align = match doc.justify {
Justify::Center | Justify::JustifyLastCenter => 1,
Justify::Right | Justify::JustifyLastRight => 2,
_ => 0,
};
let first_baseline = lay.line_boxes.first().map(|b| b[1]).unwrap_or(0.0);
let mut arc = vec![0.0f64; n];
if let Some((pm, _, force, fm, lm)) = &on_path {
let mut i = 0;
while i < n {
let line = lay.glyphs[i].line_index;
let mut j = i;
while j < n && lay.glyphs[j].line_index == line {
j += 1;
}
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + advance(gi) / 2.0).collect();
let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]);
let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force);
arc[i..j].copy_from_slice(&pos);
i = j;
}
}
let base = doc.base_style();
let mut glyphs = Vec::with_capacity(n);
for gi in 0..n {
let g = &lay.glyphs[gi];
let mut x = xfs[gi];
if g.is_space {
continue;
}
if !three {
x.offset[2] = 0.0;
x.anchor[2] = 0.0;
x.scale[2] = 100.0;
x.rotation_x = 0.0;
x.rotation_y = 0.0;
}
let o = origin(gi);
let st = lay.styles.get(g.run).unwrap_or(&base);
let size = st.size;
// Outline (substituted characters aligned in the original slot).
let (outline, adv) = match subs[gi] {
Some(c) => {
let (p, a) = char_glyph_style(st, c);
let dx = match x.char_align {
0 => 0.0,
2 => g.advance - a,
_ => (g.advance - a) / 2.0,
};
(kurbo::Affine::translate((dx, 0.0)) * p, g.advance)
}
// Variable Font Axes: the outline redrawn at the animated design-space position.
None => match x.axis_deltas() {
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), advance(gi)),
_ => (g.path.clone(), g.advance),
},
};
if outline.elements().is_empty() {
continue;
}
let pivot = match extents.get(&group_key(g)) {
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * size],
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * size],
};
// 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)
* Mat4::rotate_y(x.rotation_y)
* Mat4::rotate_x(x.rotation_x)
* 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));
let m = match &on_path {
Some((pm, perp, ..)) => {
// Position X slides along the path, Y moves off it.
let char_center = o[0] + adv / 2.0;
let s = arc[gi] + (pivot[0] - char_center) + x.offset[0];
let (p, ang) = path_text::place(pm, s, pivot[1] - first_baseline + x.offset[1], *perp);
Mat4::translate(vec3(p.x, p.y, x.offset[2])) * Mat4::rotate_z(ang) * char_m
}
None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m,
};
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
let rest = [pivot[0] - (o[0] - g.origin.x), pivot[1] - (o[1] - g.origin.y)];
glyphs.push(PlacedGlyph {
local,
m,
xf: x,
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
apply_fill: st.apply_fill,
char_index: g.char_index,
rest,
});
}
Some(TextGeom { doc, glyphs, fill_stroke, blend })
}
/// Outlines of all glyphs in layer space with their animation state (for text → shapes, bounds
/// and hit testing).
pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
}
/// One paint pass of a glyph: fill (false) or stroke (true).
type Pass = (usize, bool);
fn passes(geom: &TextGeom) -> Vec<Pass> {
let doc = &geom.doc;
let n = geom.glyphs.len();
let fill_on = |i: usize| geom.glyphs[i].apply_fill;
let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0;
let mut v = Vec::with_capacity(n * 2);
match geom.fill_stroke {
1 => {
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
}
2 => {
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
}
_ => {
for i in 0..n {
let f = fill_on(i).then_some((i, false));
let s = has_stroke(i).then_some((i, true));
if doc.stroke_over_fill {
v.extend(f);
v.extend(s);
} else {
v.extend(s);
v.extend(f);
}
}
}
}
v
}
/// Rasterize one pass of a glyph (path already in target pixels via `m`) into a premultiplied
/// patch over the target rect, blurred when the character has Blur.
fn raster_pass(g: &PlacedGlyph, stroke: bool, path: &BezPath, m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
let cov = if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
effectcraft_path::stroke_coverage(std::slice::from_ref(path), &st, &mm, w, h)
} else {
effectcraft_path::fill_coverage(std::slice::from_ref(path), &mm, w, h, FillRule::NonZero)
};
let col = if stroke { g.stroke } else { g.fill };
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
let a = col[3] * op;
let mut img = Image::new(w, h);
for (px, c) in img.data.iter_mut().zip(&cov.data) {
let k = c * a;
if k > 0.0 {
*px = [col[0] * k, col[1] * k, col[2] * k, k];
}
}
if blur_px[0] > 0.05 || blur_px[1] > 0.05 {
img = effectcraft_raster::gaussian_blur(&img, blur_px[0] / 2.0, blur_px[1] / 2.0, false);
}
img
}
fn blit(dst: &mut Image, src: &Image, at: [i64; 2], mode: BlendMode) {
for y in 0..src.height as i64 {
let ty = y + at[1];
if ty < 0 || ty >= dst.height as i64 {
continue;
}
for x in 0..src.width as i64 {
let tx = x + at[0];
if tx < 0 || tx >= dst.width as i64 {
continue;
}
let s = src.data[(y * src.width as i64 + x) as usize];
if s[3] <= 0.0 && s[0] == 0.0 && s[1] == 0.0 && s[2] == 0.0 {
continue;
}
let d = &mut dst.data[(ty * dst.width as i64 + tx) as usize];
*d = effectcraft_color::blend_pixel(mode, *d, s, 0.5);
}
}
}
/// Draw glyphs (paths mapped by `to_px` from layer space into `img`).
fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option<usize>) {
let (w, h) = (img.width as i64, img.height as i64);
for (gi, stroke) in passes(geom) {
if only.is_some_and(|o| o != gi) {
continue;
}
let g = &geom.glyphs[gi];
if g.xf.opacity <= 0.0 {
continue;
}
let m = *to_px * if only.is_some() { Mat3::IDENTITY } else { g.m2() };
let path = &g.local;
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(path)) else { continue };
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5;
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
let rect = [
((r.x0 - pad).floor() as i64).max(0),
((r.y0 - pad).floor() as i64).max(0),
((r.x1 + pad).ceil() as i64).min(w),
((r.y1 + pad).ceil() as i64).min(h),
];
if rect[2] <= rect[0] || rect[3] <= rect[1] {
continue;
}
let patch = raster_pass(g, stroke, path, &m, rect, blur);
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
}
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s };
let Some(geom) = text_geom(ctx, layer) else { return empty() };
let mut bounds: Option<kurbo::Rect> = None;
for g in &geom.glyphs {
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return empty() };
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, None);
Buf { img, offset, scale: s }
}
/// Per-character 3D: each character as its own plane — (buffer in character space, character
/// space → layer space matrix).
pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> {
let Some(geom) = text_geom(ctx, layer) else { return vec![] };
let mut out = Vec::with_capacity(geom.glyphs.len());
for (gi, g) in geom.glyphs.iter().enumerate() {
if g.xf.opacity <= 0.0 {
continue;
}
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue };
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, Some(gi));
out.push((Buf { img, offset, scale: s }, g.m));
}
out
}
#[cfg(test)]
mod tests;