Fonts, Metrics, and Rasterization

A terminal draws a grid of cells. To do that you need to answer three questions precisely: how big is a cell, where in the cell does a glyph go, and how does a glyph become pixels. Getting any of them slightly wrong produces text that looks subtly bad in a way users notice but cannot name.


Font Metrics

                    ┌─────────────────────────── line top
                    │        ▲
                    │        │ ascent
      ╭─╮  ╭──╮     │        │
      │ │  │  │     │        │
   ───┼─┼──┼──┼─────┼────────▼──── BASELINE  ← y = cell_top + ascent
      ╰─╯  ╰──╯     │        ▲
       │            │        │ descent
       ╰────────────┼────────▼
                    │        ▲
                    │        │ line_gap
                    └────────▼─────────────── line bottom

   cell_height = ascent + descent + line_gap        (all positive)
   cell_width  = advance width of a reference glyph  (e.g. 'M' or '0')
MetricMeaningWhere it comes from
AscentDistance from the baseline up to the top of the tallest glyphhhea.ascender or the OS/2 table
DescentDistance from the baseline down (usually stored negative; use the absolute value)hhea.descender
Line gapExtra leading between lineshhea.lineGap
Advance widthHow far the pen moves after drawing a glyphhmtx, per glyph
Units per emThe design grid size (commonly 1000 or 2048)head.unitsPerEm

Scaling from design units to pixels:

   pixels = design_units × (font_size_px / units_per_em)

fontdue does this for you when you ask for metrics at a size, but knowing the formula is what lets you debug a font that renders too small.

#![allow(unused)]
fn main() {
use fontdue::{Font, FontSettings};

pub struct CellMetrics {
    pub width: usize,      // physical pixels, INTEGER
    pub height: usize,     // physical pixels, INTEGER
    pub ascent: usize,     // baseline offset from the cell top, INTEGER
}

fn compute_cell_metrics(font: &Font, size_px: f32) -> CellMetrics {
    let lm = font.horizontal_line_metrics(size_px)
        .expect("font has no horizontal line metrics");

    // Cell WIDTH comes from the ADVANCE of a reference glyph, not from its
    // bounding box. The bounding box of 'i' is narrow; its advance is a full
    // cell. Using the bbox gives inconsistent, wrong cell widths.
    let advance = font.metrics('M', size_px).advance_width;

    // Round to integers. A cell width of 8.4px puts column 100 at x=840.0 and
    // column 101 at x=848.4 — glyphs land on fractional boundaries and every
    // other column renders blurry.
    CellMetrics {
        width: advance.round().max(1.0) as usize,
        height: (lm.ascent - lm.descent + lm.line_gap).round().max(1.0) as usize,
        ascent: lm.ascent.round().max(0.0) as usize,
    }
}
}

Warning: lm.descent is negative in fontdue (and in the font tables). ascent - descent is therefore ascent + |descent|, which is what you want. Writing ascent + descent gives you a cell height that is too small by twice the descent, and every descender is clipped. This is a real bug that looks like "the font is broken."


The Baseline Rule

   glyph_y = cell_top + ascent - glyph_bitmap_top
   glyph_x = cell_left + glyph_bitmap_left

Where glyph_bitmap_top/left come from the rasterizer's metrics for that glyph — they position the bitmap relative to the pen position on the baseline.

Drawing at the cell top instead of the baseline makes all text sit high, clips descenders (g, y, p, q, j), and misaligns glyphs of different heights relative to each other. It is the single most common first-render bug, and it is instantly recognizable once you have seen it.

#![allow(unused)]
fn main() {
fn draw_glyph(&mut self, ch: char, cell_col: usize, cell_row: usize, fg: Rgb, bg: Rgb) {
    let (metrics, bitmap) = self.font.rasterize(ch, self.font_size);

    let cell_x = cell_col * self.cell.width;
    let cell_y = cell_row * self.cell.height;

    // The pen sits on the BASELINE. `ymin` is the distance from the baseline to
    // the BOTTOM of the bitmap (negative for descenders), so the bitmap's top is
    // at baseline - (height + ymin).
    let baseline_y = cell_y + self.cell.ascent;
    let glyph_x = cell_x as i32 + metrics.xmin;
    let glyph_y = baseline_y as i32 - (metrics.height as i32 + metrics.ymin);

    for gy in 0..metrics.height {
        for gx in 0..metrics.width {
            let coverage = bitmap[gy * metrics.width + gx];   // 0..=255 anti-aliasing
            if coverage == 0 { continue; }
            let px = glyph_x + gx as i32;
            let py = glyph_y + gy as i32;
            // Glyphs CAN exceed their cell (descenders, accents, box drawing).
            // Clip rather than panic; a terminal must never crash on a font.
            if px < 0 || py < 0 || px >= self.width as i32 || py >= self.height as i32 { continue; }
            let dst = &mut self.buffer[py as usize * self.width + px as usize];
            *dst = blend(fg, bg, coverage);
        }
    }
}

/// Alpha-blend foreground over background by coverage.
/// NOTE: this blends in sRGB space, which is technically incorrect — proper
/// blending is linear. sRGB blending makes light-on-dark text look thin and
/// dark-on-light text look fat. Every terminal has opinions here; this is the
/// simple version, and gamma correction is a stretch goal.
fn blend(fg: Rgb, bg: Rgb, coverage: u8) -> u32 {
    let a = coverage as u32;
    let inv = 255 - a;
    let r = (fg.r as u32 * a + bg.r as u32 * inv) / 255;
    let g = (fg.g as u32 * a + bg.g as u32 * inv) / 255;
    let b = (fg.b as u32 * a + bg.b as u32 * inv) / 255;
    (r << 16) | (g << 8) | b
}
}

The Glyph Atlas

Rasterizing a glyph is expensive — hundreds of microseconds for a complex one. Doing it per cell per frame is unusable. Cache.

   ATLAS: a big texture (or CPU bitmap) holding every rasterized glyph once.

   ┌───────────────────────────────────────────┐
   │ A  B  C  D  E  F  G  H  I  J  K  L  M  N  │  ← a shelf-packed atlas
   │ O  P  Q  R  S  T  U  V  W  X  Y  Z  a  b  │
   │ c  d  e  f  g  h  i  j  k  l  m  n  o  p  │
   │ 日 本 語 🙂                                │  ← wide glyphs take two cells
   └───────────────────────────────────────────┘

   KEY: (char, font_size, weight, italic)  →  (atlas_x, atlas_y, w, h, xmin, ymin)
#![allow(unused)]
fn main() {
pub struct GlyphAtlas {
    /// Coverage bitmaps, packed. For a GPU renderer this is a texture.
    pixels: Vec<u8>,
    width: usize,
    height: usize,
    /// Shelf packing: simple, adequate for a terminal's small glyph set.
    shelf_y: usize,
    shelf_x: usize,
    shelf_height: usize,
    cache: HashMap<GlyphKey, GlyphEntry>,
    // Instrumentation: you should be able to print the hit rate.
    hits: u64,
    misses: u64,
}

#[derive(Hash, PartialEq, Eq, Copy, Clone)]
pub struct GlyphKey {
    ch: char,
    // Style affects the RASTERIZATION (bold and italic are different faces or
    // synthesized), so it must be part of the key. Forgetting this renders
    // everything in the first style that was cached.
    bold: bool,
    italic: bool,
    // Size too: a resize or a zoom invalidates every entry.
    size_px: u32,
}
}

A terminal's working set is small — typically a few hundred glyphs — so a hit rate above 99% after the first screen is normal. Print the hit rate in your debug overlay; a low rate means your key is wrong (usually a float size that is not comparing equal, which is why size_px is a u32).


Bold and Italic

Three strategies, in order of quality:

StrategyHowQuality
Real facesLoad the font family's Bold, Italic, and BoldItalic filesBest. Do this.
Synthesized boldDraw the glyph twice, offset by one pixelAcceptable fallback; looks smeared
Synthesized italicShear the bitmap horizontallyPoor; visibly wrong at the top and bottom

Font discovery (finding "the bold version of this family" on the system) is a genuinely annoying platform problem — fontconfig on Linux, Core Text on macOS, DirectWrite on Windows. For this curriculum: load four font files by path from your config. That is one line of config and zero platform code, and it is what several real terminals do anyway.


Fallback: The Missing-Glyph Problem

Your monospace font almost certainly does not contain 日, 🙂, or ⣿.

   RENDER PIPELINE with fallback:
     1. Does the primary font have a glyph for this char?  (cmap lookup)
     2. If not, try each fallback font in order.
     3. If none, draw a "tofu" box (□) or a hex box showing the codepoint.
#![allow(unused)]
fn main() {
fn glyph_for(&self, ch: char) -> (&Font, u16) {
    for font in std::iter::once(&self.primary).chain(&self.fallbacks) {
        let idx = font.lookup_glyph_index(ch);
        if idx != 0 { return (font, idx); }     // 0 is .notdef
    }
    (&self.primary, 0)                          // tofu
}
}

Warning: Fallback fonts have different metrics. An emoji font's advance is not your monospace font's advance. You must ignore the fallback font's advance and use your cell width, scaling the glyph to fit. Otherwise CJK and emoji drift out of the grid — which looks exactly like a width-calculation bug and is not one.


Text Shaping and the Monospace Assumption

The grid model assumes: one cell, one glyph, one fixed advance. That assumption is what lets you skip a shaping engine.

Where it breaks:

CaseEffectHandling
CJK / emoji2 cellsThe screen model assigns width 2; render the glyph scaled into 2 cells
Combining marks0 cellsComposed into the same cell — either rasterize base+mark together, or overlay two bitmaps
Ligatures (->, =>, != in Fira Code)n chars → 1 glyph, still n cellsRequires real shaping (GSUB). Needs swash.
Arabic / DevanagariContextual forms, reorderingFundamentally incompatible with a fixed grid
Proportional fontsVariable advanceOut of scope for a terminal

Programming ligatures are the one people ask for. The correct implementation: shape a run of cells with identical style, get the ligature glyph, and draw it spanning the run — while keeping the cell model authoritative for cursor position and selection. The cursor must still land between - and > even though they render as one glyph. That is why it is a swash-level feature and a stretch goal.


Cursor Rendering

   BLOCK      ██████    fill the cell with fg; draw the glyph in bg
   BAR        █         a 1-2px vertical bar at the cell's left edge
   UNDERLINE  ▁▁▁▁▁▁    a 1-2px horizontal bar at the cell's bottom
   HOLLOW     ┌────┐    an outline — conventionally used when unfocused
              └────┘

Rules that are easy to get wrong:

  • Respect ?25 (DECTCEM). A hidden cursor must not be drawn.
  • Damage both the old and new rows on every move, or you leave ghost blocks.
  • Block cursor over a wide character: cover both cells, or it looks broken.
  • Blinking: on a timer, and it must not keep the event loop awake at 60 fps. Wake on the blink interval only, and stop blinking while output is arriving.
  • Unfocused: convention is a hollow block. ?1004 focus reporting tells the program; the cursor style is your own decision.

Experiment

CLAIM. Font metrics are concrete numbers you can print, and cell size derives from them mechanically.

METHOD. Write a tiny program that prints your font's metrics:

fn main() {
    let data = std::fs::read(std::env::args().nth(1).unwrap()).unwrap();
    let font = Font::from_bytes(data, FontSettings::default()).unwrap();
    for size in [12.0f32, 14.0, 16.0, 24.0] {
        let lm = font.horizontal_line_metrics(size).unwrap();
        let m = font.metrics('M', size);
        let i = font.metrics('i', size);
        let g = font.metrics('g', size);
        println!("size {size}:");
        println!("  ascent={:.2} descent={:.2} line_gap={:.2}", lm.ascent, lm.descent, lm.line_gap);
        println!("  cell_h = ascent - descent + gap = {:.2}", lm.ascent - lm.descent + lm.line_gap);
        println!("  'M' advance={:.2} bbox={}x{} xmin={} ymin={}",
                 m.advance_width, m.width, m.height, m.xmin, m.ymin);
        println!("  'i' advance={:.2} bbox={}x{}  ← SAME advance, different bbox",
                 i.advance_width, i.width, i.height);
        println!("  'g' ymin={}  ← NEGATIVE: it descends below the baseline", g.ymin);
    }
}

PREDICTION. Before running: will M and i have the same advance? The same bounding box? What sign will g's ymin have?

RESULT. Record the numbers for your font. You now know exactly what "the cell is 8×17 pixels" means and where those numbers came from.


Test

#![allow(unused)]
fn main() {
#[test]
fn cell_height_uses_the_absolute_descent() {
    // descent is NEGATIVE in the font tables. ascent + descent gives a cell that
    // is too short by 2*|descent| and clips every descender.
    let m = compute_cell_metrics(&test_font(), 16.0);
    let lm = test_font().horizontal_line_metrics(16.0).unwrap();
    assert!(m.height as f32 >= lm.ascent + lm.descent.abs());
}

#[test]
fn cell_width_uses_advance_not_bounding_box() {
    // 'i' has a narrow bbox but a full-cell advance in a monospace font.
    let font = test_font();
    let m_adv = font.metrics('M', 16.0).advance_width;
    let i_adv = font.metrics('i', 16.0).advance_width;
    assert_eq!(m_adv, i_adv, "the test font must be monospace");
    assert!(font.metrics('i', 16.0).width < m_adv as usize, "but bboxes differ");
    assert_eq!(compute_cell_metrics(&font, 16.0).width, m_adv.round() as usize);
}

#[test]
fn cell_metrics_are_integers_and_nonzero() {
    for size in [8.0f32, 12.0, 16.0, 24.0, 32.0] {
        let m = compute_cell_metrics(&test_font(), size);
        assert!(m.width >= 1 && m.height >= 1, "degenerate cell at size {size}");
    }
}

#[test]
fn atlas_key_includes_style_and_size() {
    // Omitting style renders everything in whichever style was cached first.
    let mut atlas = GlyphAtlas::new(512, 512);
    let a = atlas.get_or_rasterize(GlyphKey { ch: 'A', bold: false, italic: false, size_px: 16 });
    let b = atlas.get_or_rasterize(GlyphKey { ch: 'A', bold: true, italic: false, size_px: 16 });
    assert_ne!(a.atlas_x, b.atlas_x, "bold must be a separate entry");
}

#[test]
fn atlas_hit_rate_is_high_after_warmup() {
    let mut atlas = GlyphAtlas::new(512, 512);
    let text = "the quick brown fox jumps over the lazy dog";
    for _ in 0..100 { for c in text.chars() { atlas.get(c, false, false, 16); } }
    assert!(atlas.hit_rate() > 0.99, "hit rate {} too low — check the key", atlas.hit_rate());
}

#[test]
fn glyphs_exceeding_their_cell_are_clipped_not_panicking() {
    // Descenders, accents, and box-drawing characters routinely exceed the cell.
    let mut r = Renderer::new(80, 24, test_font(), 16.0);
    r.draw_glyph('g', 79, 23, WHITE, BLACK);      // bottom-right corner
    r.draw_glyph('Ǻ', 0, 0, WHITE, BLACK);        // tall accent at the top
}
}

Challenge Extensions

  1. Gamma-correct blending. Convert to linear space, blend, convert back. Compare screenshots of light-on-dark text before and after; the difference is real.
  2. Subpixel positioning. Rasterize at several sub-pixel offsets and pick per glyph. Better spacing, larger atlas. Measure both.
  3. Font fallback chain with per-font metrics normalization, verified with CJK and emoji.
  4. Ligatures via swash, keeping the cell model authoritative for the cursor and selection.
  5. Box-drawing characters drawn programmatically rather than from the font. Many fonts have misaligned box characters, and terminals like kitty and Ghostty draw them as primitives to guarantee seamless lines. Implement ─│┌┐└┘├┤┬┴┼ and the block elements ▀▄█▌▐░▒▓.
  6. Measure rasterization cost with and without the atlas, and report the speedup.

Validation / Self-check

  1. Define ascent, descent, line gap, and advance width, and give the formula for cell height.
  2. Why is descent negative, and what bug does ascent + descent produce?
  3. Why does cell width come from the advance rather than the bounding box?
  4. Give the formula for a glyph's y position, and say what goes wrong if you draw at the cell top.
  5. What must be in the atlas cache key, and what happens if style is omitted?
  6. Why must you ignore a fallback font's advance width?
  7. Name three cases where the monospace assumption breaks and how each is handled.
  8. Why do ligatures require real shaping, and what must remain authoritative?
  9. Name four cursor rendering rules that are easy to get wrong.
  10. Why must cell dimensions be integers?
  11. What is sRGB vs. linear blending, and which direction does the error go for light-on-dark text?

Next: Rendering and Damage.