mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
This commit is contained in:
+78
-18
@@ -12,7 +12,7 @@ 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_raster::{Image, Mask};
|
||||
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};
|
||||
@@ -487,6 +487,9 @@ pub struct PlacedGlyph {
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
|
||||
/// on every side, in character space (0 when not bold).
|
||||
pub bold: f64,
|
||||
/// The character's style fills it (Character panel fill on).
|
||||
pub apply_fill: bool,
|
||||
/// Index of the character in the text.
|
||||
@@ -506,8 +509,30 @@ impl PlacedGlyph {
|
||||
pub fn path(&self) -> BezPath {
|
||||
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
|
||||
}
|
||||
/// [`Self::bold`] in layer space.
|
||||
fn bold_in_layer(&self) -> f64 {
|
||||
self.bold * self.m2().mean_scale()
|
||||
}
|
||||
/// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space:
|
||||
/// no bold, or a matrix that only rotates and scales uniformly.
|
||||
fn bold_is_even(&self) -> bool {
|
||||
let [[a, b, _], [c, d, _], _] = self.m2().0;
|
||||
let n = a * a + b * b + c * c + d * d;
|
||||
self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a circle of radius `r` reaches along x and along y once mapped by `m`.
|
||||
fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] {
|
||||
let [[a, b, _], [c, d, _], _] = m.0;
|
||||
[r * a.hypot(b), r * c.hypot(d)]
|
||||
}
|
||||
|
||||
/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter
|
||||
/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a
|
||||
/// missing bold face.
|
||||
const FAUX_BOLD_RADIUS: f64 = 0.03;
|
||||
|
||||
/// Laid-out, animated text of a layer at the context time.
|
||||
pub struct TextGeom {
|
||||
pub doc: TextDoc,
|
||||
@@ -785,6 +810,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
|
||||
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
|
||||
apply_fill: st.apply_fill,
|
||||
char_index: g.char_index,
|
||||
rest,
|
||||
@@ -834,17 +860,41 @@ fn passes(geom: &TextGeom) -> Vec<Pass> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill
|
||||
/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased.
|
||||
fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask {
|
||||
let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero);
|
||||
if d.abs() < 1e-9 {
|
||||
return cov;
|
||||
}
|
||||
let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() };
|
||||
let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h);
|
||||
for (c, r) in cov.data.iter_mut().zip(&ring.data) {
|
||||
*c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) };
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths
|
||||
/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred
|
||||
/// when the character has Blur.
|
||||
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
|
||||
/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space.
|
||||
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> 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 cov = if stroke && bold > 0.0 {
|
||||
// The stroke of the bold outline: the band between it grown and shrunk by half the width.
|
||||
let half = g.stroke_width / 2.0;
|
||||
let mut outer = grown_coverage(paths, &mm, w, h, bold + half);
|
||||
let inner = grown_coverage(paths, &mm, w, h, bold - half);
|
||||
for (o, i) in outer.data.iter_mut().zip(&inner.data) {
|
||||
*o = o.min(1.0 - i);
|
||||
}
|
||||
outer
|
||||
} else if stroke {
|
||||
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
|
||||
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
|
||||
} else {
|
||||
effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero)
|
||||
grown_coverage(paths, &mm, w, h, bold)
|
||||
};
|
||||
let col = if stroke { g.stroke } else { g.fill };
|
||||
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
|
||||
@@ -900,10 +950,16 @@ fn runs(geom: &TextGeom) -> Vec<(Vec<usize>, bool)> {
|
||||
if g.xf.opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// A run grows its outline in layer space by one radius, which matches each glyph grown in
|
||||
// its own space only when the bold stays round there.
|
||||
if geom.blend == BlendMode::Normal
|
||||
&& solid_fill(g, stroke)
|
||||
&& g.bold_is_even()
|
||||
&& let Some((run, false)) = out.last_mut()
|
||||
&& run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill)
|
||||
&& run.last().is_some_and(|&p| {
|
||||
let q = &geom.glyphs[p];
|
||||
solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6
|
||||
})
|
||||
{
|
||||
run.push(gi);
|
||||
continue;
|
||||
@@ -925,28 +981,31 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
|
||||
// One glyph is drawn from its own outline; a run from its glyphs' outlines in layer
|
||||
// space, all wound the same way so overlaps add up instead of cancelling (NonZero).
|
||||
let local = Cow::Borrowed(std::slice::from_ref(&g.local));
|
||||
let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) {
|
||||
(Some(_), _) => (local, *to_px),
|
||||
(None, [_]) => (local, *to_px * g.m2()),
|
||||
let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) {
|
||||
(Some(_), _) => (local, *to_px, g.bold),
|
||||
(None, [_]) => (local, *to_px * g.m2(), g.bold),
|
||||
(None, _) => {
|
||||
let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p };
|
||||
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px)
|
||||
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer())
|
||||
}
|
||||
};
|
||||
let Some(b) = effectcraft_path::bounds(&paths) 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;
|
||||
// Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach
|
||||
// further along one axis than the other.
|
||||
let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold);
|
||||
let pad = 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),
|
||||
((r.x0 - px - pad).floor() as i64).max(0),
|
||||
((r.y0 - py - pad).floor() as i64).max(0),
|
||||
((r.x1 + px + pad).ceil() as i64).min(w),
|
||||
((r.y1 + py + pad).ceil() as i64).min(h),
|
||||
];
|
||||
if rect[2] <= rect[0] || rect[3] <= rect[1] {
|
||||
continue;
|
||||
}
|
||||
let patch = raster_pass(g, stroke, &paths, &m, rect, blur);
|
||||
let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold);
|
||||
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
|
||||
}
|
||||
}
|
||||
@@ -958,8 +1017,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
|
||||
let mut bounds: Option<kurbo::Rect> = None;
|
||||
for g in &geom.glyphs {
|
||||
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
|
||||
let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold);
|
||||
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 b = b.inflate(pad + bx, pad + by);
|
||||
bounds = Some(bounds.map_or(b, |a| a.union(b)));
|
||||
}
|
||||
}
|
||||
@@ -984,7 +1044,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)>
|
||||
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 pad = g.stroke_width + 2.0 * g.bold + 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);
|
||||
|
||||
@@ -542,6 +542,7 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
fill,
|
||||
stroke: [0.0; 4],
|
||||
stroke_width: 0.0,
|
||||
bold: 0.0,
|
||||
apply_fill: true,
|
||||
char_index: 0,
|
||||
rest: [0.0; 2],
|
||||
@@ -569,6 +570,64 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
assert!(multiply[3] < 0.9, "{multiply:?}");
|
||||
}
|
||||
|
||||
/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn.
|
||||
#[test]
|
||||
fn faux_bold_and_synthetic_bold_thicken_the_glyphs() {
|
||||
// Total alpha and the alpha bounding box of a rendered word.
|
||||
let ink = |d: TextDoc| {
|
||||
let (mut p, cid, comp) = setup(400, 120);
|
||||
let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d });
|
||||
set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0]));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
(img.data.iter().map(|px| px[3]).sum::<f32>(), alpha_bbox(&img).unwrap())
|
||||
};
|
||||
let (plain, pb) = ink(TextDoc::default());
|
||||
let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() });
|
||||
assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}");
|
||||
// 0.03 em = 1.8 px on every side.
|
||||
let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]);
|
||||
assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}");
|
||||
// Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold.
|
||||
let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() });
|
||||
let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() });
|
||||
assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}");
|
||||
// A stroke follows the bold outline: as wide as before, moved out with it.
|
||||
let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() };
|
||||
let (ring, rb) = ink(stroked.clone());
|
||||
let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked });
|
||||
assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}");
|
||||
assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}");
|
||||
}
|
||||
|
||||
/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it
|
||||
/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run.
|
||||
#[test]
|
||||
fn faux_bold_under_a_non_uniform_scale() {
|
||||
use kurbo::Shape;
|
||||
let glyph = |x0: f64| PlacedGlyph {
|
||||
local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1),
|
||||
m: Mat4::scale(vec3(9.0, 1.0, 1.0)),
|
||||
xf: CharXf::default(),
|
||||
fill: [1.0; 4],
|
||||
stroke: [0.0; 4],
|
||||
stroke_width: 0.0,
|
||||
bold: 2.0,
|
||||
apply_fill: true,
|
||||
char_index: 0,
|
||||
rest: [0.0; 2],
|
||||
};
|
||||
let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal };
|
||||
assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one");
|
||||
// x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18.
|
||||
let mut img = crate::Image::new(120, 24);
|
||||
draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None);
|
||||
for (x, y) in [(37, 10), (106, 10), (70, 3)] {
|
||||
assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y));
|
||||
}
|
||||
assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01);
|
||||
}
|
||||
|
||||
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
|
||||
#[test]
|
||||
fn range_bar_drag_values() {
|
||||
|
||||
Reference in New Issue
Block a user