Skip to main content

dotloom_render/
text.rs

1//! Text: font loading, signed-distance-field glyph atlas and layout.
2//!
3//! Glyph outlines are read from the TrueType `glyf` table with `read-fonts` (simple
4//! and composite glyphs), rasterized once at [`RASTER_PX`] by a small coverage
5//! rasterizer, converted to a signed distance field and packed into
6//! a single-channel atlas. The SDF stays sharp from a few pixels up to large zoom
7//! levels, so text never needs to be re-rasterized while zooming.
8//!
9//! Layout follows the scene contract (`Text::height` is the cap-to-descender
10//! height; lines are `1.2 × height` apart). With the default font, glyph positions
11//! come from [`Text::line_pens`] — the advance widths and pair kerning shared with
12//! `dotloom-geometry` — so drawn text and the engine's text boxes (hit-testing,
13//! culling) agree exactly. Other fonts are laid out with their own advances.
14
15use std::collections::HashMap;
16
17use dotloom_geometry::{HAlign, Text, VAlign};
18use read_fonts::{
19    FontRef, TableProvider,
20    tables::glyf::{Anchor, CompositeGlyphFlags, CurvePoint, Glyph},
21    types::GlyphId,
22};
23
24use crate::RenderError;
25
26/// The default font: a subset of Inter Regular (SIL OFL 1.1, see `assets/`).
27pub const DEFAULT_FONT: &[u8] = include_bytes!("../assets/Inter-Regular-subset.ttf");
28
29/// Rasterization size of atlas glyphs (pixels per em).
30pub const RASTER_PX: f32 = 48.0;
31/// SDF spread and glyph padding in raster pixels.
32pub const SPREAD: usize = 6;
33/// Atlas edge length (fits WebGL2's guaranteed 2048 texture limit).
34pub const ATLAS_SIZE: usize = 2048;
35
36/// Line distance relative to the text height (scene contract).
37const LINE_SPACING: f64 = Text::LINE_SPACING;
38/// Ascent used for vertical alignment (scene contract: top ≈ 0.8 h above baseline).
39const TOP_ABOVE_BASELINE: f64 = Text::TOP_ABOVE_BASELINE;
40/// Curve flattening tolerance in raster pixels.
41const FLATTEN_TOLERANCE: f32 = 0.1;
42
43/// A glyph in the atlas.
44#[derive(Debug, Clone, Copy)]
45pub(crate) struct GlyphSlot {
46    /// Atlas UV rectangle `[u0, v0, u1, v1]` (v down); `None` for blank glyphs.
47    pub uv: Option<[f32; 4]>,
48    /// Quad in em units relative to the pen position on the baseline (y up):
49    /// `[x0, y0, x1, y1]`.
50    pub quad: [f32; 4],
51    /// Advance in em units.
52    pub advance: f32,
53}
54
55/// One positioned glyph (text-local model units before rotation, y up).
56#[derive(Debug, Clone, Copy)]
57pub(crate) struct PlacedGlyph {
58    pub x0: f64,
59    pub y0: f64,
60    pub x1: f64,
61    pub y1: f64,
62    pub uv: [f32; 4],
63}
64
65/// Dirty atlas region to upload.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) struct DirtyRect {
68    pub x: usize,
69    pub y: usize,
70    pub w: usize,
71    pub h: usize,
72}
73
74struct Shelf {
75    y: usize,
76    h: usize,
77    x: usize,
78}
79
80/// Font, atlas and glyph cache.
81pub struct TextSystem {
82    /// Font file (validated by [`TextSystem::new`]).
83    font: Vec<u8>,
84    /// The default font: positions come from the shared layout tables.
85    shared_layout: bool,
86    /// Text height → em size factor: `em = height / height_per_em`.
87    height_per_em: f64,
88    glyphs: HashMap<u32, GlyphSlot>,
89    pub(crate) atlas: Vec<u8>,
90    shelves: Vec<Shelf>,
91    pub(crate) dirty: Option<DirtyRect>,
92    /// Incremented when the atlas is cleared; cached text meshes become stale.
93    pub(crate) generation: u64,
94}
95
96impl std::fmt::Debug for TextSystem {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("TextSystem").field("glyphs", &self.glyphs.len()).field("generation", &self.generation).finish()
99    }
100}
101
102/// Receives glyph outlines (y up).
103trait OutlinePen {
104    fn move_to(&mut self, x: f32, y: f32);
105    fn line_to(&mut self, x: f32, y: f32);
106    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32);
107    fn close(&mut self);
108}
109
110/// Bounding box of an outline (raster pixels, y up).
111#[derive(Default)]
112struct Bounds {
113    min: (f32, f32),
114    max: (f32, f32),
115    any: bool,
116}
117
118impl OutlinePen for Bounds {
119    fn move_to(&mut self, x: f32, y: f32) {
120        self.add(x, y);
121    }
122    fn line_to(&mut self, x: f32, y: f32) {
123        self.add(x, y);
124    }
125    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
126        // The control polygon contains the curve.
127        self.add(cx0, cy0);
128        self.add(x, y);
129    }
130    fn close(&mut self) {}
131}
132
133impl Bounds {
134    fn add(&mut self, x: f32, y: f32) {
135        if !self.any {
136            self.min = (x, y);
137            self.max = (x, y);
138            self.any = true;
139        }
140        self.min = (self.min.0.min(x), self.min.1.min(y));
141        self.max = (self.max.0.max(x), self.max.1.max(y));
142    }
143}
144
145/// Coverage rasterizer: signed-area accumulation (the approach of font-rs). Each
146/// line segment adds the area it covers to the cells it crosses; a running sum
147/// over every row turns the deltas into non-zero winding coverage.
148struct Raster {
149    w: usize,
150    h: usize,
151    /// Accumulation cells (`w × h` plus slack for the cell right of a row).
152    acc: Vec<f32>,
153    /// Offset from outline coordinates (y up) to raster pixels (y down).
154    origin: (f32, f32),
155    start: (f32, f32),
156    cur: (f32, f32),
157}
158
159impl Raster {
160    fn new(w: usize, h: usize, origin: (f32, f32)) -> Self {
161        Self { w, h, acc: vec![0.0; w * h + 2], origin, start: (0.0, 0.0), cur: (0.0, 0.0) }
162    }
163
164    fn to_px(&self, x: f32, y: f32) -> (f32, f32) {
165        (x - self.origin.0, self.origin.1 - y)
166    }
167
168    fn add(&mut self, i: isize, v: f32) {
169        if let Some(cell) = usize::try_from(i).ok().and_then(|i| self.acc.get_mut(i)) {
170            *cell += v;
171        }
172    }
173
174    fn line(&mut self, p0: (f32, f32), p1: (f32, f32)) {
175        if p0.1 == p1.1 || !(p0.0.is_finite() && p0.1.is_finite() && p1.0.is_finite() && p1.1.is_finite()) {
176            return;
177        }
178        let (dir, p0, p1) = if p0.1 < p1.1 { (1.0f32, p0, p1) } else { (-1.0f32, p1, p0) };
179        let dxdy = (p1.0 - p0.0) / (p1.1 - p0.1);
180        let y_start = p0.1.max(0.0);
181        let y_end = p1.1.min(self.h as f32);
182        let mut x = p0.0 + (y_start - p0.1) * dxdy;
183        let mut y = y_start.floor();
184        while y < y_end {
185            let row = y as isize * self.w as isize;
186            let dy = (y + 1.0).min(y_end) - y.max(y_start);
187            let x_next = x + dxdy * dy;
188            let d = dy * dir;
189            let (x0, x1) = if x < x_next { (x, x_next) } else { (x_next, x) };
190            let x0 = x0.clamp(0.0, self.w as f32);
191            let x1 = x1.clamp(0.0, self.w as f32);
192            let x0_floor = x0.floor();
193            let x0i = x0_floor as isize;
194            let x1_ceil = x1.ceil();
195            let x1i = x1_ceil as isize;
196            if x1i <= x0i + 1 {
197                // The segment stays within one cell in this row.
198                let xm = 0.5 * (x0 + x1) - x0_floor;
199                self.add(row + x0i, d - d * xm);
200                self.add(row + x0i + 1, d * xm);
201            } else {
202                let s = (x1 - x0).recip();
203                let x0f = x0 - x0_floor;
204                let a0 = 0.5 * s * (1.0 - x0f) * (1.0 - x0f);
205                let x1f = x1 - x1_ceil + 1.0;
206                let am = 0.5 * s * x1f * x1f;
207                self.add(row + x0i, d * a0);
208                if x1i == x0i + 2 {
209                    self.add(row + x0i + 1, d * (1.0 - a0 - am));
210                } else {
211                    let a1 = s * (1.5 - x0f);
212                    self.add(row + x0i + 1, d * (a1 - a0));
213                    for xi in x0i + 2..x1i - 1 {
214                        self.add(row + xi, d * s);
215                    }
216                    let a2 = a1 + (x1i - x0i - 3) as f32 * s;
217                    self.add(row + x1i - 1, d * (1.0 - a2 - am));
218                }
219                self.add(row + x1i, d * am);
220            }
221            x = x_next;
222            y += 1.0;
223        }
224    }
225
226    fn line_to_px(&mut self, p: (f32, f32)) {
227        let from = self.cur;
228        self.line(from, p);
229        self.cur = p;
230    }
231
232    fn coverage(&self) -> Vec<u8> {
233        let mut sum = 0.0f32;
234        self.acc
235            .iter()
236            .take(self.w * self.h)
237            .map(|a| {
238                sum += a;
239                (sum.abs().min(1.0) * 255.0).round() as u8
240            })
241            .collect()
242    }
243}
244
245impl OutlinePen for Raster {
246    fn move_to(&mut self, x: f32, y: f32) {
247        let p = self.to_px(x, y);
248        self.start = p;
249        self.cur = p;
250    }
251    fn line_to(&mut self, x: f32, y: f32) {
252        let p = self.to_px(x, y);
253        self.line_to_px(p);
254    }
255    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
256        let (p0, c, p2) = (self.cur, self.to_px(cx0, cy0), self.to_px(x, y));
257        let dd = ((p0.0 - 2.0 * c.0 + p2.0).powi(2) + (p0.1 - 2.0 * c.1 + p2.1).powi(2)).sqrt();
258        let n = ((dd / FLATTEN_TOLERANCE).sqrt().ceil() as usize).clamp(1, 64);
259        for i in 1..=n {
260            let t = i as f32 / n as f32;
261            let mt = 1.0 - t;
262            let p = (
263                mt * mt * p0.0 + 2.0 * mt * t * c.0 + t * t * p2.0,
264                mt * mt * p0.1 + 2.0 * mt * t * c.1 + t * t * p2.1,
265            );
266            self.line_to_px(p);
267        }
268    }
269    fn close(&mut self) {
270        let start = self.start;
271        self.line_to_px(start);
272    }
273}
274
275impl TextSystem {
276    /// Load a TrueType/OpenType font.
277    ///
278    /// # Errors
279    /// [`RenderError::Font`] when the font cannot be parsed or lacks metrics.
280    pub fn new(font_bytes: &[u8]) -> Result<Self, RenderError> {
281        let font = FontRef::new(font_bytes).map_err(|e| RenderError::Font(e.to_string()))?;
282        let err = |e: read_fonts::ReadError| RenderError::Font(e.to_string());
283        let upm = f32::from(font.head().map_err(err)?.units_per_em());
284        let hhea = font.hhea().map_err(err)?;
285        font.glyf().map_err(err)?;
286        font.hmtx().map_err(err)?;
287        if upm <= 0.0 {
288            return Err(RenderError::Font("font has no units per em".into()));
289        }
290        let ascent = f32::from(hhea.ascender().to_i16());
291        let cap = map_char(&font, 'H').and_then(|g| outline_bounds(&font, g, upm)).map_or(ascent * 0.75, |b| b.max.1);
292        let descent = (-f32::from(hhea.descender().to_i16())).max(0.0);
293        let shared_layout = font_bytes == DEFAULT_FONT;
294        // The default font is scaled exactly like `dotloom-geometry` measures it.
295        let height_per_em = if shared_layout { 1.0 / Text::em_size(1.0) } else { f64::from((cap + descent) / upm) };
296        if !(height_per_em.is_finite() && height_per_em > 0.1) {
297            return Err(RenderError::Font("implausible font metrics".into()));
298        }
299        Ok(Self {
300            font: font_bytes.to_vec(),
301            shared_layout,
302            height_per_em,
303            glyphs: HashMap::new(),
304            atlas: vec![0; ATLAS_SIZE * ATLAS_SIZE],
305            shelves: Vec::new(),
306            dirty: None,
307            generation: 0,
308        })
309    }
310
311    /// The built-in font.
312    ///
313    /// # Errors
314    /// Never for the bundled font; see [`TextSystem::new`].
315    pub fn with_default_font() -> Result<Self, RenderError> {
316        Self::new(DEFAULT_FONT)
317    }
318
319    /// Number of glyphs in the atlas.
320    #[must_use]
321    pub fn cached_glyphs(&self) -> usize {
322        self.glyphs.len()
323    }
324
325    fn clear_atlas(&mut self) {
326        self.glyphs.clear();
327        self.shelves.clear();
328        self.atlas.fill(0);
329        self.dirty = Some(DirtyRect { x: 0, y: 0, w: ATLAS_SIZE, h: ATLAS_SIZE });
330        self.generation += 1;
331    }
332
333    fn mark_dirty(&mut self, r: DirtyRect) {
334        self.dirty = Some(match self.dirty {
335            None => r,
336            Some(d) => {
337                let x0 = d.x.min(r.x);
338                let y0 = d.y.min(r.y);
339                let x1 = (d.x + d.w).max(r.x + r.w);
340                let y1 = (d.y + d.h).max(r.y + r.h);
341                DirtyRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }
342            }
343        });
344    }
345
346    fn allocate(&mut self, w: usize, h: usize) -> Option<(usize, usize)> {
347        if w > ATLAS_SIZE || h > ATLAS_SIZE {
348            return None;
349        }
350        for s in &mut self.shelves {
351            if h <= s.h && s.x + w <= ATLAS_SIZE {
352                let at = (s.x, s.y);
353                s.x += w + 1;
354                return Some(at);
355            }
356        }
357        let y = self.shelves.last().map_or(0, |s| s.y + s.h + 1);
358        if y + h > ATLAS_SIZE {
359            return None;
360        }
361        self.shelves.push(Shelf { y, h, x: w + 1 });
362        Some((0, y))
363    }
364
365    /// Glyph of a character, with the shared substitutions; `.notdef` if missing.
366    fn glyph_index(&self, c: char) -> u32 {
367        let Ok(font) = FontRef::new(&self.font) else { return 0 };
368        let substitute = match c {
369            '\u{2300}' => Some('\u{2205}'),
370            '\u{00a0}' | '\u{2007}' | '\u{202f}' => Some(' '),
371            _ => None,
372        };
373        map_char(&font, c)
374            .or_else(|| substitute.and_then(|s| map_char(&font, s)))
375            .or_else(|| map_char(&font, '\u{fffd}'))
376            .map_or(0, GlyphId::to_u32)
377    }
378
379    /// Get or rasterize a glyph. Returns `None` only when the atlas is full even
380    /// after clearing it (more distinct glyphs than fit in one atlas).
381    fn glyph(&mut self, index: u32) -> Option<GlyphSlot> {
382        if let Some(g) = self.glyphs.get(&index) {
383            return Some(*g);
384        }
385        let font = FontRef::new(&self.font).ok()?;
386        let gid = GlyphId::new(index);
387        let upm = f32::from(font.head().ok()?.units_per_em());
388        let advance = f32::from(font.hmtx().ok()?.advance(gid).unwrap_or(0)) / upm;
389        let bounds = outline_bounds(&font, gid, RASTER_PX);
390        let Some(b) = bounds.filter(|b| b.max.0 > b.min.0 && b.max.1 > b.min.1) else {
391            let slot = GlyphSlot { uv: None, quad: [0.0; 4], advance };
392            self.glyphs.insert(index, slot);
393            return Some(slot);
394        };
395        let (xmin, ymin) = (b.min.0.floor(), b.min.1.floor());
396        let (gw, gh) = ((b.max.0.ceil() - xmin) as usize, (b.max.1.ceil() - ymin) as usize);
397        let mut raster = Raster::new(gw, gh, (xmin, ymin + gh as f32));
398        let s = RASTER_PX / upm;
399        draw_glyph(&font, gid, [s, 0.0, 0.0, s, 0.0, 0.0], 0, &mut raster)?;
400        let coverage = raster.coverage();
401        let (w, h) = (gw + 2 * SPREAD, gh + 2 * SPREAD);
402        let at = match self.allocate(w, h) {
403            Some(at) => at,
404            None => {
405                self.clear_atlas();
406                self.allocate(w, h)?
407            }
408        };
409        let sdf = signed_distance_field(&coverage, gw, gh, SPREAD);
410        for row in 0..h {
411            let dst = (at.1 + row) * ATLAS_SIZE + at.0;
412            self.atlas[dst..dst + w].copy_from_slice(&sdf[row * w..(row + 1) * w]);
413        }
414        self.mark_dirty(DirtyRect { x: at.0, y: at.1, w, h });
415        let size = ATLAS_SIZE as f32;
416        let pad = SPREAD as f32;
417        let slot = GlyphSlot {
418            uv: Some([at.0 as f32 / size, at.1 as f32 / size, (at.0 + w) as f32 / size, (at.1 + h) as f32 / size]),
419            quad: [
420                (xmin - pad) / RASTER_PX,
421                (ymin - pad) / RASTER_PX,
422                (xmin + gw as f32 + pad) / RASTER_PX,
423                (ymin + gh as f32 + pad) / RASTER_PX,
424            ],
425            advance,
426        };
427        self.glyphs.insert(index, slot);
428        Some(slot)
429    }
430
431    /// Characters of one line with their pen positions in em units, and the line's
432    /// advance in em units. Returns `None` if the atlas overflowed.
433    fn pens(&mut self, line: &str) -> Option<(Vec<(char, f64)>, f64)> {
434        if self.shared_layout {
435            let units = 1.0 / Text::FONT_UNITS_PER_EM;
436            let (pens, width) = Text::line_pens(line);
437            return Some((pens.into_iter().map(|(c, x)| (c, x * units)).collect(), width * units));
438        }
439        let mut pens = Vec::with_capacity(line.len());
440        let mut pen = 0.0;
441        for c in line.trim_end_matches('\r').chars().filter_map(normalize_char) {
442            pens.push((c, pen));
443            pen += f64::from(self.glyph(self.glyph_index(c))?.advance);
444        }
445        Some((pens, pen))
446    }
447
448    /// Measure the advance width of one line in model units.
449    #[cfg(test)]
450    pub(crate) fn line_width(&mut self, line: &str, height: f64) -> f64 {
451        let em = height / self.height_per_em;
452        self.pens(line).map_or(0.0, |p| p.1) * em
453    }
454
455    /// Lay out text into glyph quads (text-local coordinates, y up, before
456    /// rotation and translation). Returns `None` if the atlas overflowed while
457    /// laying out (caller retries after the generation change).
458    pub(crate) fn layout(&mut self, t: &Text) -> Option<Vec<PlacedGlyph>> {
459        let h = t.height;
460        if !(h.is_finite() && h > 0.0) {
461            return Some(Vec::new());
462        }
463        let em = h / self.height_per_em;
464        let lines: Vec<&str> = t.content.split('\n').collect();
465        let n = lines.len().max(1) as f64;
466        let block_h = h * (1.0 + (n - 1.0) * LINE_SPACING);
467        let first_baseline = match t.valign {
468            VAlign::Baseline => 0.0,
469            VAlign::Top => -TOP_ABOVE_BASELINE * h,
470            VAlign::Middle => block_h * 0.5 - TOP_ABOVE_BASELINE * h,
471            VAlign::Bottom => block_h - TOP_ABOVE_BASELINE * h,
472        };
473        let generation = self.generation;
474        let mut out = Vec::with_capacity(t.content.len());
475        for (i, line) in lines.iter().enumerate() {
476            let (pens, width) = self.pens(line)?;
477            let width = width * em;
478            let start = match t.halign {
479                HAlign::Left => 0.0,
480                HAlign::Center => -width * 0.5,
481                HAlign::Right => -width,
482            };
483            let baseline = first_baseline - i as f64 * LINE_SPACING * h;
484            for (c, x) in pens {
485                let g = self.glyph(self.glyph_index(c))?;
486                if self.generation != generation {
487                    return None;
488                }
489                if let Some(uv) = g.uv {
490                    let pen = start + x * em;
491                    out.push(PlacedGlyph {
492                        x0: pen + f64::from(g.quad[0]) * em,
493                        y0: baseline + f64::from(g.quad[1]) * em,
494                        x1: pen + f64::from(g.quad[2]) * em,
495                        y1: baseline + f64::from(g.quad[3]) * em,
496                        uv,
497                    });
498                }
499            }
500        }
501        Some(out)
502    }
503}
504
505/// Outline bounds of a glyph at `size` pixels per em (y up).
506fn outline_bounds(font: &FontRef<'_>, gid: GlyphId, size: f32) -> Option<Bounds> {
507    let s = size / f32::from(font.head().ok()?.units_per_em());
508    let mut b = Bounds::default();
509    draw_glyph(font, gid, [s, 0.0, 0.0, s, 0.0, 0.0], 0, &mut b)?;
510    b.any.then_some(b)
511}
512
513/// The nominal glyph of a character.
514fn map_char(font: &FontRef<'_>, c: char) -> Option<GlyphId> {
515    font.cmap().ok()?.map_codepoint(c).filter(|g| g.to_u32() != 0)
516}
517
518/// Draw a `glyf` glyph through an affine map `[xx, yx, xy, yy, dx, dy]`
519/// (`x' = xx·x + xy·y + dx`, `y' = yx·x + yy·y + dy`). Composite glyphs are drawn
520/// component by component (nesting is bounded).
521fn draw_glyph(font: &FontRef<'_>, gid: GlyphId, m: [f32; 6], depth: u8, pen: &mut impl OutlinePen) -> Option<()> {
522    if depth > 8 {
523        return None;
524    }
525    let glyf = font.glyf().ok()?;
526    let glyph = font.loca(None).ok()?.get(gid, &glyf)?.into_glyph();
527    match glyph {
528        None => Some(()),
529        Some(Glyph::Simple(g)) => {
530            let points: Vec<CurvePoint> = g.points().collect();
531            let mut first = 0usize;
532            for end in g.end_pts_of_contours() {
533                let end = usize::from(end.get());
534                draw_contour(points.get(first..=end)?, m, pen);
535                first = end + 1;
536            }
537            Some(())
538        }
539        Some(Glyph::Composite(g)) => {
540            for c in g.components() {
541                let t = c.transform;
542                let (a, b, cc, d) = (t.xx.to_f32(), t.yx.to_f32(), t.xy.to_f32(), t.yy.to_f32());
543                let (mut ox, mut oy) = match c.anchor {
544                    Anchor::Offset { x, y } => (f32::from(x), f32::from(y)),
545                    // Point-matched placement is not used by the bundled font.
546                    Anchor::Point { .. } => (0.0, 0.0),
547                };
548                if c.flags.contains(CompositeGlyphFlags::SCALED_COMPONENT_OFFSET) {
549                    (ox, oy) = (a * ox + cc * oy, b * ox + d * oy);
550                }
551                // Child map: parent ∘ (component matrix, then offset).
552                let child = [
553                    m[0] * a + m[2] * b,
554                    m[1] * a + m[3] * b,
555                    m[0] * cc + m[2] * d,
556                    m[1] * cc + m[3] * d,
557                    m[0] * ox + m[2] * oy + m[4],
558                    m[1] * ox + m[3] * oy + m[5],
559                ];
560                draw_glyph(font, GlyphId::from(c.glyph), child, depth + 1, pen)?;
561            }
562            Some(())
563        }
564    }
565}
566
567/// One TrueType contour: on-curve points are ends, off-curve points are quadratic
568/// controls, and two consecutive controls imply an on-curve midpoint.
569fn draw_contour(points: &[CurvePoint], m: [f32; 6], pen: &mut impl OutlinePen) {
570    let n = points.len();
571    if n == 0 {
572        return;
573    }
574    let map = |p: &CurvePoint| {
575        let (x, y) = (f32::from(p.x), f32::from(p.y));
576        (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
577    };
578    let mid = |a: (f32, f32), b: (f32, f32)| (0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1));
579    let (start, first) = match points.iter().position(|p| p.on_curve) {
580        Some(i) => (points.get(i).map_or((0.0, 0.0), map), i),
581        None => (mid(points.get(n - 1).map_or((0.0, 0.0), map), points.first().map_or((0.0, 0.0), map)), n - 1),
582    };
583    pen.move_to(start.0, start.1);
584    let mut control: Option<(f32, f32)> = None;
585    for k in 1..=n {
586        let Some(p) = points.get((first + k) % n) else { continue };
587        let q = map(p);
588        if p.on_curve {
589            match control.take() {
590                Some(c) => pen.quad_to(c.0, c.1, q.0, q.1),
591                None => pen.line_to(q.0, q.1),
592            }
593        } else {
594            if let Some(c) = control {
595                let implied = mid(c, q);
596                pen.quad_to(c.0, c.1, implied.0, implied.1);
597            }
598            control = Some(q);
599        }
600    }
601    if let Some(c) = control {
602        pen.quad_to(c.0, c.1, start.0, start.1);
603    }
604    pen.close();
605}
606
607/// Map control characters: tabs become spaces, other controls are dropped.
608fn normalize_char(c: char) -> Option<char> {
609    match c {
610        '\t' => Some(' '),
611        c if c.is_control() => None,
612        c => Some(c),
613    }
614}
615
616/// 1-D squared Euclidean distance transform (Felzenszwalb & Huttenlocher).
617fn edt_1d(f: &[f32], d: &mut [f32], v: &mut [usize], z: &mut [f32]) {
618    let n = f.len();
619    if n == 0 {
620        return;
621    }
622    let mut k = 0usize;
623    v[0] = 0;
624    z[0] = f32::NEG_INFINITY;
625    z[1] = f32::INFINITY;
626    for q in 1..n {
627        let fq = f[q] + (q * q) as f32;
628        // z[0] is -∞, so the loop always stops at k = 0.
629        let mut s;
630        loop {
631            let p = v[k];
632            s = (fq - (f[p] + (p * p) as f32)) / (2.0 * q as f32 - 2.0 * p as f32);
633            if s <= z[k] && k > 0 {
634                k -= 1;
635            } else {
636                break;
637            }
638        }
639        k += 1;
640        v[k] = q;
641        z[k] = s;
642        z[k + 1] = f32::INFINITY;
643    }
644    k = 0;
645    for (q, out) in d.iter_mut().enumerate().take(n) {
646        while z[k + 1] < q as f32 {
647            k += 1;
648        }
649        let p = v[k];
650        let dq = q as f32 - p as f32;
651        *out = dq * dq + f[p];
652    }
653}
654
655/// 2-D squared distance to the nearest pixel where `feature` is true.
656fn edt_2d(feature: &[bool], w: usize, h: usize) -> Vec<f32> {
657    const INF: f32 = 1e20;
658    let mut grid: Vec<f32> = feature.iter().map(|&b| if b { 0.0 } else { INF }).collect();
659    let n = w.max(h);
660    let (mut f, mut d) = (vec![0.0f32; n], vec![0.0f32; n]);
661    let (mut v, mut z) = (vec![0usize; n], vec![0.0f32; n + 1]);
662    for x in 0..w {
663        for y in 0..h {
664            f[y] = grid[y * w + x];
665        }
666        edt_1d(&f[..h], &mut d[..h], &mut v, &mut z);
667        for y in 0..h {
668            grid[y * w + x] = d[y];
669        }
670    }
671    for y in 0..h {
672        f[..w].copy_from_slice(&grid[y * w..(y + 1) * w]);
673        edt_1d(&f[..w], &mut d[..w], &mut v, &mut z);
674        grid[y * w..(y + 1) * w].copy_from_slice(&d[..w]);
675    }
676    grid
677}
678
679/// Signed distance field of a coverage bitmap, padded by `spread` on each side.
680/// Encoded as `0.5` on the outline, increasing inside; `spread` pixels map to ±0.5.
681pub(crate) fn signed_distance_field(coverage: &[u8], w: usize, h: usize, spread: usize) -> Vec<u8> {
682    let (pw, ph) = (w + 2 * spread, h + 2 * spread);
683    let mut cov = vec![0u8; pw * ph];
684    for y in 0..h {
685        let dst = (y + spread) * pw + spread;
686        cov[dst..dst + w].copy_from_slice(&coverage[y * w..(y + 1) * w]);
687    }
688    let inside: Vec<bool> = cov.iter().map(|&c| c >= 128).collect();
689    let outside: Vec<bool> = inside.iter().map(|&b| !b).collect();
690    let to_inside = edt_2d(&inside, pw, ph);
691    let to_outside = edt_2d(&outside, pw, ph);
692    let s = spread as f32;
693    cov.iter()
694        .enumerate()
695        .map(|(i, &c)| {
696            // Signed distance in pixels, positive inside.
697            let sd = if c > 0 && c < 255 {
698                f32::from(c) / 255.0 - 0.5
699            } else if inside[i] {
700                to_outside[i].sqrt() - 0.5
701            } else {
702                -(to_inside[i].sqrt() - 0.5)
703            };
704            ((0.5 + sd / (2.0 * s)).clamp(0.0, 1.0) * 255.0).round() as u8
705        })
706        .collect()
707}
708
709#[cfg(test)]
710mod tests {
711    use dotloom_geometry::Point;
712
713    use super::*;
714
715    /// Rasterize closed polygons (raster pixels, y down: the origin maps y = 0 to row 0).
716    fn raster(w: usize, h: usize, polys: &[&[(f32, f32)]]) -> Vec<u8> {
717        let mut r = Raster::new(w, h, (0.0, 0.0));
718        for poly in polys {
719            r.move_to(poly[0].0, -poly[0].1);
720            for p in &poly[1..] {
721                r.line_to(p.0, -p.1);
722            }
723            r.close();
724        }
725        r.coverage()
726    }
727
728    #[test]
729    fn rasterizer_coverage_area_and_winding() {
730        // A 7 × 7 square on half-pixel boundaries: 49 px of area, half-covered edges.
731        let sq: &[(f32, f32)] = &[(1.5, 1.5), (8.5, 1.5), (8.5, 8.5), (1.5, 8.5)];
732        let cov = raster(10, 10, &[sq]);
733        let area: f32 = cov.iter().map(|&c| f32::from(c) / 255.0).sum();
734        assert!((area - 49.0).abs() < 0.1, "area {area}");
735        assert_eq!(cov[5 * 10 + 5], 255);
736        assert_eq!(cov[5 * 10 + 1], 128);
737        assert_eq!(cov[0], 0);
738        // Same direction twice (winding 2): the inside stays fully covered (non-zero
739        // rule, clamped), the outside stays empty.
740        let twice = raster(10, 10, &[sq, sq]);
741        for i in 0..100 {
742            if cov[i] == 255 || cov[i] == 0 {
743                assert_eq!(twice[i], cov[i], "pixel {i}");
744            }
745        }
746        // An inner square in the opposite direction cuts a hole.
747        let hole: &[(f32, f32)] = &[(3.0, 3.0), (3.0, 7.0), (7.0, 7.0), (7.0, 3.0)];
748        let ring = raster(10, 10, &[sq, hole]);
749        assert_eq!(ring[5 * 10 + 5], 0);
750        let ring_area: f32 = ring.iter().map(|&c| f32::from(c) / 255.0).sum();
751        assert!((ring_area - 33.0).abs() < 0.1, "ring {ring_area}");
752        // A diagonal edge: the triangle below the diagonal covers half the square.
753        let tri: &[(f32, f32)] = &[(0.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
754        let t: f32 = raster(10, 10, &[tri]).iter().map(|&c| f32::from(c) / 255.0).sum();
755        assert!((t - 50.0).abs() < 0.1, "triangle {t}");
756    }
757
758    #[test]
759    fn outlines_match_the_glyph_boxes_of_the_font() {
760        // Every glyph, simple or composite, drawn in font units has exactly the
761        // bounding box stored in its glyf header (which counts all points).
762        let font = FontRef::new(DEFAULT_FONT).unwrap();
763        let upm = f32::from(font.head().unwrap().units_per_em());
764        let glyf = font.glyf().unwrap();
765        let loca = font.loca(None).unwrap();
766        let (mut simple, mut composite) = (0, 0);
767        for id in 0..font.maxp().unwrap().num_glyphs() {
768            let gid = GlyphId::new(u32::from(id));
769            let Some(glyph) = loca.get(gid, &glyf).unwrap().into_glyph() else { continue };
770            let header = [glyph.x_min(), glyph.y_min(), glyph.x_max(), glyph.y_max()].map(f32::from);
771            match glyph {
772                Glyph::Simple(_) => simple += 1,
773                Glyph::Composite(_) => composite += 1,
774            }
775            let b = outline_bounds(&font, gid, upm).unwrap();
776            assert_eq!([b.min.0, b.min.1, b.max.0, b.max.1], header, "glyph {id}");
777        }
778        assert!(simple > 300 && composite > 100, "{simple} simple, {composite} composite");
779    }
780
781    #[test]
782    fn glyph_rasters_have_counters_and_plausible_ink() {
783        let font = FontRef::new(DEFAULT_FONT).unwrap();
784        // "O" has a counter: empty in the middle, inked on its sides.
785        let gid = map_char(&font, 'O').unwrap();
786        let b = outline_bounds(&font, gid, RASTER_PX).unwrap();
787        let (xmin, ymin) = (b.min.0.floor(), b.min.1.floor());
788        let (w, h) = ((b.max.0.ceil() - xmin) as usize, (b.max.1.ceil() - ymin) as usize);
789        let mut r = Raster::new(w, h, (xmin, ymin + h as f32));
790        let s = RASTER_PX / f32::from(font.head().unwrap().units_per_em());
791        draw_glyph(&font, gid, [s, 0.0, 0.0, s, 0.0, 0.0], 0, &mut r).unwrap();
792        let cov = r.coverage();
793        assert_eq!(cov[(h / 2) * w + w / 2], 0, "counter");
794        assert!(cov[(h / 2) * w + 1] > 200, "left stroke");
795        assert!(cov[(h / 2) * w + w - 2] > 200, "right stroke");
796        // Ink of every glyph stays inside its quad and the SDF is finite.
797        let mut ts = TextSystem::with_default_font().unwrap();
798        for c in "AgjÇğİ@%∅".chars() {
799            let g = ts.glyph(ts.glyph_index(c)).unwrap();
800            assert!(g.uv.is_some() && g.quad[2] > g.quad[0] && g.quad[3] > g.quad[1], "{c}");
801        }
802    }
803
804    fn text(s: &str) -> Text {
805        Text {
806            position: Point::new(0.0, 0.0),
807            content: s.into(),
808            height: 10.0,
809            rotation: 0.0,
810            halign: HAlign::Left,
811            valign: VAlign::Baseline,
812        }
813    }
814
815    #[test]
816    fn turkish_and_symbols_have_glyphs() {
817        let ts = TextSystem::with_default_font().unwrap();
818        let font = FontRef::new(DEFAULT_FONT).unwrap();
819        for c in "çÇğĞıİöÖşŞüܰ±×∅µ²€ΩπДж".chars() {
820            assert!(map_char(&font, c).is_some(), "missing {c}");
821            assert_ne!(ts.glyph_index(c), 0, "{c}");
822        }
823        // Diameter sign falls back to the empty-set glyph.
824        assert_eq!(ts.glyph_index('\u{2300}'), ts.glyph_index('\u{2205}'));
825    }
826
827    #[test]
828    fn layout_metrics_follow_the_contract() {
829        let mut ts = TextSystem::with_default_font().unwrap();
830        let g = ts.layout(&text("H")).unwrap();
831        assert_eq!(g.len(), 1);
832        // Cap height of Inter ≈ 0.75 × text height (cap + descender = height).
833        let cap_top = g[0].y1 - (SPREAD as f64 / f64::from(RASTER_PX)) * (10.0 / ts.height_per_em);
834        assert!((cap_top - 7.5).abs() < 0.3, "cap top {cap_top}");
835        // Second line is 1.2 h lower.
836        let two = ts.layout(&text("H\nH")).unwrap();
837        assert!((two[0].y0 - two[1].y0 - 12.0).abs() < 1e-9);
838    }
839
840    #[test]
841    fn headless_text_metrics_match_the_font() {
842        // dotloom-geometry lays text out with generated tables
843        // (crates/render/assets/text-metrics.py) that the renderer also uses for
844        // the default font. Check them against the font file itself: every mapped
845        // character's advance, the units per em and the vertical scale.
846        let mut ts = TextSystem::with_default_font().unwrap();
847        let font = FontRef::new(DEFAULT_FONT).unwrap();
848        let upm = f32::from(font.head().unwrap().units_per_em());
849        assert_eq!(f64::from(upm), Text::FONT_UNITS_PER_EM);
850        let hmtx = font.hmtx().unwrap();
851        let mut checked = 0;
852        for cp in 0..=0xffff_u32 {
853            let Some(c) = char::from_u32(cp).filter(|c| !c.is_control()) else { continue };
854            let Some(gid) = map_char(&font, c) else { continue };
855            let want = f64::from(hmtx.advance(gid).unwrap());
856            assert_eq!(Text::line_pens(&c.to_string()).1, want, "advance of U+{cp:04X}");
857            checked += 1;
858        }
859        assert!(checked > 900, "{checked}");
860        let cap = outline_bounds(&font, map_char(&font, 'H').unwrap(), upm).unwrap().max.1;
861        let descender = -f32::from(font.hhea().unwrap().descender().to_i16());
862        assert!((1.0 / Text::em_size(1.0) - f64::from((cap + descender) / upm)).abs() < 1e-12);
863        // Missing characters advance like `.notdef`, which is what gets drawn.
864        let notdef = f64::from(hmtx.advance(GlyphId::NOTDEF).unwrap());
865        assert_eq!(Text::line_pens("\u{e000}").1, notdef);
866        assert_eq!(ts.glyph_index('\u{e000}'), 0);
867        // The renderer's line widths are the shared ones, kerning included.
868        for s in ["Dotloom 0123 ABC xyz", "Ölçü ğüşıİç ÇĞÖŞÜ", "W i\tm", "∅ 40 mm ⌀ ±0,5 €", "Дж Ωπ", "AVATAR"]
869        {
870            let want = ts.line_width(s, 10.0);
871            let got = Text::line_width(s, 10.0);
872            assert!((got - want).abs() <= 1e-9 * want.max(1.0), "{s:?}: geometry {got} vs renderer {want}");
873        }
874        // "AV" is drawn kerned: V sits left of where A's advance alone puts it.
875        let em = Text::em_size(10.0);
876        let kerned = ts.layout(&text("AV")).unwrap();
877        let a_advance = f64::from(ts.glyph(ts.glyph_index('A')).unwrap().advance) * em;
878        let v_left = f64::from(ts.glyph(ts.glyph_index('V')).unwrap().quad[0]) * em;
879        assert!(kerned[1].x0 < a_advance + v_left - 0.01, "{} vs {}", kerned[1].x0, a_advance + v_left);
880        // The layout box encloses every drawn glyph quad (minus the SDF padding).
881        let t = Text { content: "Ölçü\nÇĞÖŞÜ gjpq".into(), ..text("") };
882        let b = t.layout_box();
883        let pad = SPREAD as f64 / f64::from(RASTER_PX) * (t.height / ts.height_per_em);
884        for g in ts.layout(&t).unwrap() {
885            assert!(g.x0 + pad >= b.min.x - 1e-6 && g.x1 - pad <= b.max.x + 1e-6, "x {g:?} {b:?}");
886            assert!(g.y0 + pad >= b.min.y - 1e-6 && g.y1 - pad <= b.max.y + 1e-6, "y {g:?} {b:?}");
887        }
888    }
889
890    #[test]
891    fn alignment() {
892        let mut ts = TextSystem::with_default_font().unwrap();
893        let mut t = text("İstanbul");
894        let w = ts.line_width(&t.content, t.height);
895        assert!(w > 20.0 && w < 60.0, "{w}");
896        t.halign = HAlign::Right;
897        let g = ts.layout(&t).unwrap();
898        // Quads include the SDF padding (SPREAD / RASTER_PX em ≈ 1.3 units here).
899        let pad = SPREAD as f64 / f64::from(RASTER_PX) * (t.height / ts.height_per_em);
900        let right = g.last().unwrap().x1 - pad;
901        assert!(right <= 0.1 && right > -2.0, "{right}");
902        t.halign = HAlign::Center;
903        t.valign = VAlign::Middle;
904        let g = ts.layout(&t).unwrap();
905        let min_x = g.iter().map(|q| q.x0).fold(f64::INFINITY, f64::min);
906        assert!((min_x + w * 0.5).abs() < 1.5);
907    }
908
909    #[test]
910    fn sdf_is_monotonic_across_an_edge() {
911        // A filled square in a 10×10 bitmap.
912        let mut cov = vec![0u8; 100];
913        for y in 3..7 {
914            for x in 3..7 {
915                cov[y * 10 + x] = 255;
916            }
917        }
918        let sdf = signed_distance_field(&cov, 10, 10, 4);
919        let pw = 18;
920        let row = 4 + 5;
921        let vals: Vec<u8> = (0..pw).map(|x| sdf[row * pw + x]).collect();
922        let center = vals[9];
923        assert!(center > 128, "{vals:?}");
924        assert!(vals[0] < 64);
925        for x in 1..9 {
926            assert!(vals[x] >= vals[x - 1], "{vals:?}");
927        }
928    }
929
930    #[test]
931    fn controls_are_skipped_and_empty_text_is_fine() {
932        let mut ts = TextSystem::with_default_font().unwrap();
933        assert!(ts.layout(&text("")).unwrap().is_empty());
934        assert_eq!(ts.layout(&text("a\u{7}b")).unwrap().len(), 2);
935        let mut bad = text("x");
936        bad.height = f64::NAN;
937        assert!(ts.layout(&bad).unwrap().is_empty());
938    }
939}