diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..856b2f578 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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 { 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 { 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, 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 = 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); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..3e6800aba 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -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::(), 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() {