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')
| Metric | Meaning | Where it comes from |
|---|---|---|
| Ascent | Distance from the baseline up to the top of the tallest glyph | hhea.ascender or the OS/2 table |
| Descent | Distance from the baseline down (usually stored negative; use the absolute value) | hhea.descender |
| Line gap | Extra leading between lines | hhea.lineGap |
| Advance width | How far the pen moves after drawing a glyph | hmtx, per glyph |
| Units per em | The 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.descentis negative infontdue(and in the font tables).ascent - descentis thereforeascent + |descent|, which is what you want. Writingascent + descentgives 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:
| Strategy | How | Quality |
|---|---|---|
| Real faces | Load the font family's Bold, Italic, and BoldItalic files | Best. Do this. |
| Synthesized bold | Draw the glyph twice, offset by one pixel | Acceptable fallback; looks smeared |
| Synthesized italic | Shear the bitmap horizontally | Poor; 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:
| Case | Effect | Handling |
|---|---|---|
| CJK / emoji | 2 cells | The screen model assigns width 2; render the glyph scaled into 2 cells |
| Combining marks | 0 cells | Composed into the same cell — either rasterize base+mark together, or overlay two bitmaps |
Ligatures (->, =>, != in Fira Code) | n chars → 1 glyph, still n cells | Requires real shaping (GSUB). Needs swash. |
| Arabic / Devanagari | Contextual forms, reordering | Fundamentally incompatible with a fixed grid |
| Proportional fonts | Variable advance | Out 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.
?1004focus 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
- Gamma-correct blending. Convert to linear space, blend, convert back. Compare screenshots of light-on-dark text before and after; the difference is real.
- Subpixel positioning. Rasterize at several sub-pixel offsets and pick per glyph. Better spacing, larger atlas. Measure both.
- Font fallback chain with per-font metrics normalization, verified with CJK and emoji.
- Ligatures via
swash, keeping the cell model authoritative for the cursor and selection. - 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▀▄█▌▐░▒▓. - Measure rasterization cost with and without the atlas, and report the speedup.
Validation / Self-check
- Define ascent, descent, line gap, and advance width, and give the formula for cell height.
- Why is
descentnegative, and what bug doesascent + descentproduce? - Why does cell width come from the advance rather than the bounding box?
- Give the formula for a glyph's y position, and say what goes wrong if you draw at the cell top.
- What must be in the atlas cache key, and what happens if style is omitted?
- Why must you ignore a fallback font's advance width?
- Name three cases where the monospace assumption breaks and how each is handled.
- Why do ligatures require real shaping, and what must remain authoritative?
- Name four cursor rendering rules that are easy to get wrong.
- Why must cell dimensions be integers?
- What is sRGB vs. linear blending, and which direction does the error go for light-on-dark text?
Next: Rendering and Damage.