Text: draw Faux Bold and synthetic bold (#1676)

The layout marks a glyph synth_bold when Faux Bold is on, or when a bold
style is asked of a family without a bold face, but the renderer never
read it, so both drew as regular.

PlacedGlyph now carries the bold radius (0.03 em, which brings Inter
Regular's stems to Inter Bold's). A bold fill is the outline's coverage
merged with a round stroke twice that wide, and a bold glyph's stroke is
the band between the outline grown by radius ± half the stroke width, so
it follows the bolder shape. Runs of solid fills only merge glyphs of the
same bold radius, and the patch and layer bounds grow by it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
heojunfo
2026-10-19 10:36:16 +09:00
co-authored by Claude Opus 5.5
parent ef74d82902
commit a5f096324d
2 changed files with 85 additions and 14 deletions
+54 -14
View File
@@ -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};
@@ -479,6 +479,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.
@@ -498,8 +501,17 @@ 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()
}
}
/// 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,
@@ -777,6 +789,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,
@@ -826,17 +839,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;
@@ -895,7 +932,10 @@ fn runs(geom: &TextGeom) -> Vec<(Vec<usize>, bool)> {
if geom.blend == BlendMode::Normal
&& solid_fill(g, stroke)
&& 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_in_layer() - g.bold_in_layer()).abs() < 1e-6
})
{
run.push(gi);
continue;
@@ -917,17 +957,17 @@ 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;
let pad = (g.stroke_width + 2.0 * bold) * 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),
@@ -938,7 +978,7 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
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 });
}
}
@@ -950,7 +990,7 @@ 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 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_in_layer() + 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)));
}
@@ -976,7 +1016,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);
+31
View File
@@ -524,6 +524,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],
@@ -551,6 +552,36 @@ 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:?}");
}
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
#[test]
fn range_bar_drag_values() {