diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -16,6 +16,7 @@
 pub mod bidi;
 mod complex;
 mod mtext;
+pub mod shx;
 mod stroke;
 pub mod subset;
 pub mod ttf;
@@ -212,6 +213,62 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh
     out
 }
 
+/// Shape one line in an SHX font: each character's shape program scaled so the font's capital
+/// height is the text height; characters the font (and its big font) lack come from the stroke
+/// font. A line with right-to-left letters (Hebrew) is laid out in display order: its
+/// right-to-left runs reversed, brackets mirrored, the base direction from its first letter.
+fn shape_shx(font: &TextFont, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped {
+    let TextFont::Shx { font, big } = font else { return shape_stroke(s, height, width_factor, oblique) };
+    let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
+    let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
+    let shear = oblique.tan().clamp(-10.0, 10.0);
+    let mut out = Shaped::default();
+    let mut x = 0.0;
+    let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new();
+    let decoded = decode_controls(s);
+    let ordered: Vec<(char, bool, bool)> = if decoded.iter().any(|(c, _, _)| bidi::has_rtl(&c.to_string())) {
+        let chars: Vec<char> = decoded.iter().map(|(c, _, _)| *c).collect();
+        let mut v = Vec::with_capacity(decoded.len());
+        for (r, rtl) in bidi::visual_runs(&chars, None) {
+            let run = decoded.get(r).unwrap_or_default();
+            if rtl {
+                v.extend(run.iter().rev().map(|(c, u, o)| (bidi::mirror(*c), *u, *o)));
+            } else {
+                v.extend(run.iter().copied());
+            }
+        }
+        v
+    } else {
+        decoded
+    };
+    for (c, under, over) in ordered {
+        if bidi::is_format_control(c) {
+            continue;
+        }
+        let x0 = x;
+        let from = |f: &shx::ShxFont| f.code_for(c).and_then(|code| f.glyph(code)).map(|g| (g, h / f.above));
+        match from(font).or_else(|| big.as_deref().and_then(from)) {
+            Some((g, sc)) => {
+                for st in &g.strokes {
+                    out.strokes.push(st.iter().map(|p| Vec2::new(x + p.x * sc * wf + p.y * sc * shear, p.y * sc)).collect());
+                }
+                x += g.advance * sc * wf;
+            }
+            None => {
+                let mut piece = shape_stroke(&c.to_string(), h, wf, oblique);
+                piece.map(|p| p + Vec2::new(x, 0.0));
+                let adv = stroke_advance(c, h, wf);
+                out.extend(piece);
+                x += adv;
+            }
+        }
+        spans.push((x0, x, under, over));
+    }
+    out.strokes.extend(decorations(&spans, h));
+    out.width = x.max(0.0);
+    out
+}
+
 /// Draw stroke-font glyph `spec` with its left edge at `x` (`sc` = text height / cap height).
 fn draw_stroke_glyph(out: &mut Shaped, spec: &str, x: f64, sc: f64, wf: f64, shear: f64) {
     for st in stroke::strokes(spec) {
@@ -263,6 +320,9 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 {
 /// Width of the gap MTEXT leaves between two words. Words are measured without their trailing
 /// letter gap, so the stroke font's gap is added on both sides of the space, as TEXT lays it out.
 pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 {
+    if let TextFont::Shx { .. } = font {
+        return shape_shx(font, " ", height, width_factor, 0.0).width;
+    }
     if let TextFont::Outline(face) = font
         && let Some(w) = ttf::width(face, " ", height, width_factor)
     {
@@ -278,13 +338,36 @@ pub enum TextFont {
     #[default]
     Stroke,
     Outline(ttf::Face),
+    /// An SHX shape font from the user's font folders, with the style's big font when it has one.
+    Shx {
+        font: std::sync::Arc<shx::ShxFont>,
+        big: Option<std::sync::Arc<shx::ShxFont>>,
+    },
+}
+
+/// Whether a style font name means an SHX file: `.shx`, or no extension at all (`romans`).
+fn names_shx(name: &str) -> bool {
+    let n = name.trim();
+    let file = n.rsplit(['\\', '/']).next().unwrap_or(n);
+    file.to_ascii_lowercase().ends_with(".shx") || !file.contains('.')
 }
 
 impl TextFont {
-    /// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we
-    /// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set;
-    /// else the stroke font.
+    /// Resolve a text style font name: an SHX font in the user's font folders for an SHX name;
+    /// an installed TTF/OTF/TTC when found; else, for a font we can't find (an SHX file, a
+    /// missing TTF), the FONTALT font ([`ttf::font_alt`]) when set; else the stroke font.
     pub fn resolve(name: &str) -> TextFont {
+        Self::resolve_style(name, "")
+    }
+
+    /// [`TextFont::resolve`] with the style's big font (for an SHX font).
+    pub fn resolve_style(name: &str, big_font: &str) -> TextFont {
+        if names_shx(name)
+            && let Some(font) = shx::find(name)
+        {
+            let big = Some(big_font).filter(|b| !b.trim().is_empty()).and_then(shx::find);
+            return TextFont::Shx { font, big };
+        }
         if let Some(f) = ttf::find(name) {
             return TextFont::Outline(f);
         }
@@ -300,7 +383,8 @@ impl TextFont {
 }
 
 /// Shaped text in local or world coordinates: open strokes (stroke-font glyphs, underlines,
-/// fraction bars) and closed glyph outlines grouped per glyph (fill each group even-odd).
+/// fraction bars) and closed glyph outlines grouped per glyph (fill each group by the non-zero rule, as
+/// TrueType does).
 /// Outline glyphs also come as [`TextGlyph`]s, for output that keeps text as text (PDF).
 #[derive(Clone, Debug, Default, PartialEq)]
 pub struct Shaped {
@@ -414,6 +498,9 @@ pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec<Vec<V
 /// Shape one line in `font` (baseline at y = 0, x from 0). TrueType fonts that fail to parse
 /// fall back to the stroke font.
 pub fn shape_line(font: &TextFont, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped {
+    if let TextFont::Shx { .. } = font {
+        return shape_shx(font, s, height, width_factor, oblique);
+    }
     if let TextFont::Outline(face) = font
         && let Some(sh) = ttf::shape(face, s, height, width_factor, oblique)
     {
@@ -424,6 +511,9 @@ pub fn shape_line(font: &TextFont, s: &str, height: f64, width_factor: f64, obli
 
 /// Width of one line in `font`.
 pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 {
+    if let TextFont::Shx { .. } = font {
+        return shape_shx(font, s, height, width_factor, 0.0).width;
+    }
     if let TextFont::Outline(face) = font
         && let Some(w) = ttf::width(face, s, height, width_factor)
     {
diff --git a/crates/fonts/src/shx.rs b/crates/fonts/src/shx.rs
new file mode 100644
--- /dev/null
+++ b/crates/fonts/src/shx.rs
@@ -0,0 +1,899 @@
+//! SHX shape fonts (AutoCAD's compiled `.shx` files): the drawing's own stroke fonts, read from
+//! the user's font folders at run time. Nothing of any SHX font ships with CADCraft.
+//!
+//! Three file kinds are read (format as published in the AutoCAD Customization Guide, "Shape
+//! Descriptions"):
+//! - `shapes 1.0` / `1.1`: a table of (shape number, length) after three counts, then each shape
+//!   as a NUL-terminated name and its bytes. Shape 0 holds the font's above / below heights.
+//! - `unifont 1.0`: a shape count and the font information record, then each shape as number,
+//!   length and bytes (shape numbers are Unicode code points).
+//! - `bigfont 1.0`: escape-byte ranges, then a table of (number, length, offset).
+//!
+//! A shape is a little program for a pen: vector bytes (length in the high nibble, one of 16
+//! directions in the low one) and the special codes 0–14 (pen up / down, scale, push / pop,
+//! sub-shapes, displacements, octant, fractional and bulge arcs, vertical-only commands).
+//! [`ShxFont::glyph`] runs it into polylines in font units; the end position is the advance.
+//!
+//! Everything read from the file is hostile: offsets and lengths are checked, programs are
+//! bounded in steps, sub-shape nesting and points, and arcs in segments.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
+
+use cadcraft_geom::Vec2;
+
+/// Largest SHX file read.
+const MAX_FILE_BYTES: u64 = 16 << 20;
+/// Most shapes taken from one file.
+const MAX_SHAPES: usize = 70_000;
+/// Most bytes one glyph program may run (sub-shapes included).
+const MAX_STEPS: usize = 20_000;
+/// Deepest sub-shape nesting.
+const MAX_DEPTH: usize = 8;
+/// Most points one glyph may produce.
+const MAX_POINTS: usize = 50_000;
+/// Deepest position stack (the format allows 4; some fonts push more).
+const MAX_STACK: usize = 16;
+
+/// What a shape file is.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+    Shapes,
+    Unifont,
+    Bigfont,
+}
+
+/// A parsed SHX font: its shapes' programs by shape number, and its metrics.
+#[derive(Debug)]
+pub struct ShxFont {
+    pub name: String,
+    pub kind: Kind,
+    /// Height of capitals above the baseline, in font units (text height covers this).
+    pub above: f64,
+    /// Depth of descenders below the baseline, in font units.
+    pub below: f64,
+    shapes: HashMap<u32, Vec<u8>>,
+    /// Hebrew letters at their Windows-1255 / DOS 862 places (worked out once, when read).
+    hebrew: (bool, bool),
+}
+
+/// A glyph run into strokes: polylines in font units, baseline at y = 0, and the pen's final
+/// x (the advance to the next character).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Glyph {
+    pub strokes: Vec<Vec<Vec2>>,
+    pub advance: f64,
+}
+
+fn u16le(b: &[u8], i: usize) -> Option<u16> {
+    Some(u16::from_le_bytes([*b.get(i)?, *b.get(i + 1)?]))
+}
+fn u32le(b: &[u8], i: usize) -> Option<u32> {
+    Some(u32::from_le_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
+}
+
+/// The bytes after a shape's NUL-terminated name.
+fn after_name(def: &[u8]) -> &[u8] {
+    match def.iter().position(|b| *b == 0) {
+        Some(n) => def.get(n + 1..).unwrap_or_default(),
+        None => &[],
+    }
+}
+
+impl ShxFont {
+    /// Parse SHX bytes; `None` when they are not a shape font this reader knows.
+    pub fn parse(name: &str, b: &[u8]) -> Option<ShxFont> {
+        let head = b.get(..b.len().min(40))?;
+        let text: String = head.iter().take_while(|c| **c != 0x1a).map(|c| *c as char).collect();
+        let start = text.len() + 1;
+        let lower = text.to_ascii_lowercase();
+        if !lower.starts_with("autocad-86") {
+            return None;
+        }
+        let mut shapes: HashMap<u32, Vec<u8>> = HashMap::new();
+        let (kind, mut above, mut below);
+        if lower.contains("unifont") {
+            kind = Kind::Unifont;
+            let count = usize::try_from(u32le(b, start)?).ok()?.min(MAX_SHAPES);
+            let info_len = usize::from(u16le(b, start + 4)?);
+            let info = b.get(start + 6..start + 6 + info_len)?;
+            let rest = after_name(info);
+            above = f64::from(*rest.first().unwrap_or(&0));
+            below = f64::from(*rest.get(1).unwrap_or(&0));
+            let mut i = start + 6 + info_len;
+            for _ in 0..count {
+                let (Some(num), Some(len)) = (u16le(b, i), u16le(b, i + 2)) else { break };
+                let len = usize::from(len);
+                let Some(def) = b.get(i + 4..i + 4 + len) else { break };
+                shapes.insert(u32::from(num), after_name(def).to_vec());
+                i += 4 + len;
+            }
+        } else if lower.contains("bigfont") {
+            kind = Kind::Bigfont;
+            let count = usize::from(u16le(b, start)?).min(MAX_SHAPES);
+            let ranges = usize::from(u16le(b, start + 2)?);
+            let mut i = start + 4 + ranges * 4;
+            above = 0.0;
+            below = 0.0;
+            // The table's records may point at the same bytes: what is copied is bounded in
+            // total, not only per record.
+            let mut copied = 0usize;
+            for _ in 0..count {
+                let (Some(num), Some(len), Some(off)) = (u16le(b, i), u16le(b, i + 2), u32le(b, i + 4)) else { break };
+                i += 8;
+                let (len, off) = (usize::from(len), usize::try_from(off).ok()?);
+                let Some(def) = b.get(off..off.saturating_add(len)) else { continue };
+                copied = copied.saturating_add(len);
+                if copied > MAX_FILE_BYTES as usize {
+                    break;
+                }
+                let body = after_name(def);
+                if num == 0 {
+                    above = f64::from(*body.first().unwrap_or(&0));
+                } else {
+                    shapes.insert(u32::from(num), body.to_vec());
+                }
+            }
+        } else if lower.contains("shapes") {
+            kind = Kind::Shapes;
+            let count = usize::from(u16le(b, start + 4)?).min(MAX_SHAPES);
+            let mut index = Vec::with_capacity(count);
+            let mut i = start + 6;
+            for _ in 0..count {
+                let (Some(num), Some(len)) = (u16le(b, i), u16le(b, i + 2)) else { break };
+                index.push((num, usize::from(len)));
+                i += 4;
+            }
+            above = 0.0;
+            below = 0.0;
+            for (num, len) in index {
+                let Some(def) = b.get(i..i + len) else { break };
+                i += len;
+                let body = after_name(def);
+                if num == 0 {
+                    above = f64::from(*body.first().unwrap_or(&0));
+                    below = f64::from(*body.get(1).unwrap_or(&0));
+                } else {
+                    shapes.insert(u32::from(num), body.to_vec());
+                }
+            }
+        } else {
+            return None;
+        }
+        if above <= 0.0 {
+            // A font without its information shape: take the capital letter's height.
+            above = 10.0;
+        }
+        let mut f = ShxFont { name: name.to_string(), kind, above, below, shapes, hebrew: (false, false) };
+        f.hebrew = (f.find_hebrew_1255(), f.find_hebrew_862());
+        Some(f)
+    }
+
+    /// Whether the font draws shape `code`.
+    pub fn has(&self, code: u32) -> bool {
+        self.shapes.contains_key(&code)
+    }
+
+    /// How many of `codes` the font draws.
+    fn count(&self, codes: std::ops::RangeInclusive<u32>) -> usize {
+        codes.filter(|c| self.shapes.contains_key(c)).count()
+    }
+
+    /// A one-byte font with Hebrew letters at their Windows-1255 places (0xE0..0xFA) and not a
+    /// Cyrillic Windows-1251 font (which fills 0xC0..0xDF with capitals as well).
+    fn find_hebrew_1255(&self) -> bool {
+        self.kind != Kind::Unifont && self.count(0xE0..=0xFA) >= 20 && self.count(0xC0..=0xDF) < 16
+    }
+
+    /// A one-byte font with Hebrew letters at their DOS 862 places (0x80..0x9A) and not a
+    /// Cyrillic DOS 866 font (which also has small letters at 0xA0..0xAF).
+    fn find_hebrew_862(&self) -> bool {
+        self.kind != Kind::Unifont && self.count(0x80..=0x9A) >= 20 && self.count(0xA0..=0xAF) < 8
+    }
+
+    fn hebrew_1255(&self) -> bool {
+        self.hebrew.0
+    }
+    fn hebrew_862(&self) -> bool {
+        self.hebrew.1
+    }
+
+    /// The shape that draws `c`: its code point, or for a one-byte font a Hebrew letter at
+    /// its Windows-1255 or DOS (862) position, and the old AutoCAD positions of °, ± and ⌀.
+    pub fn code_for(&self, c: char) -> Option<u32> {
+        let u = u32::from(c);
+        // A big font numbers its shapes by its code page's double-byte codes, not by Unicode:
+        // a character beyond one byte is not looked for there by its code point (it would draw
+        // an unrelated shape); it comes from the main or the built-in font instead.
+        if self.kind == Kind::Bigfont && u > 0xFF {
+            return None;
+        }
+        let mut tries: Vec<u32> = vec![u];
+        // Only a font that holds Hebrew there: a Cyrillic font has its letters at the same
+        // places (Windows-1251 0xC0..0xFF, DOS 866 0x80..0xAF), and Hebrew text must not turn
+        // into Russian.
+        if (0x05D0..=0x05EA).contains(&u) {
+            if self.hebrew_1255() {
+                tries.push(u - 0x05D0 + 0xE0);
+            }
+            if self.hebrew_862() {
+                tries.push(u - 0x05D0 + 0x80);
+            }
+        }
+        // °, ± and ⌀: their Unicode places in a Unicode font; in a one-byte font AutoCAD's own
+        // places (127, 128, 129) unless those hold Hebrew letters (DOS 862), and never a
+        // Windows code page place, which other code pages use for letters (Ш, ר…). A font
+        // without them draws them from the built-in font.
+        let unicode = self.kind == Kind::Unifont;
+        let legacy = !unicode && !self.hebrew_862();
+        match c {
+            '°' => {
+                if unicode {
+                    tries.push(0xB0);
+                }
+                if legacy {
+                    tries.push(0x7F);
+                }
+            }
+            '±' => {
+                if unicode {
+                    tries.push(0xB1);
+                }
+                if legacy {
+                    tries.push(0x80);
+                }
+            }
+            '⌀' | '∅' | 'Ø' => {
+                tries.extend([0x2205, 0x2300]);
+                if unicode {
+                    tries.push(0xD8);
+                }
+                if legacy {
+                    tries.push(0x81);
+                }
+            }
+            _ => {}
+        }
+        tries.into_iter().find(|t| self.has(*t))
+    }
+
+    /// Run shape `code` into strokes (font units).
+    pub fn glyph(&self, code: u32) -> Option<Glyph> {
+        let prog = self.shapes.get(&code)?;
+        let mut pen = Pen { pos: Vec2::ZERO, down: true, scale: 1.0, stack: Vec::new(), strokes: Vec::new(), cur: Vec::new(), steps: 0, points: 0 };
+        self.run(prog, &mut pen, 0);
+        pen.lift();
+        Some(Glyph { strokes: pen.strokes, advance: pen.pos.x })
+    }
+
+    fn run(&self, p: &[u8], pen: &mut Pen, depth: usize) {
+        let mut i = 0usize;
+        let byte = |i: usize| p.get(i).copied();
+        let sbyte = |i: usize| p.get(i).map(|b| f64::from(*b as i8));
+        // Code 14: the next command is for vertical text only; it is read and not drawn.
+        let mut skip = false;
+        while i < p.len() {
+            pen.steps += 1;
+            if pen.steps > MAX_STEPS || pen.points > MAX_POINTS {
+                return;
+            }
+            let Some(code) = byte(i) else { return };
+            i += 1;
+            let draw = !skip;
+            skip = false;
+            if code >= 0x10 {
+                let len = f64::from(code >> 4);
+                let d = DIRS.get(usize::from(code & 0x0f)).copied().unwrap_or((1.0, 0.0));
+                if draw {
+                    pen.move_by(Vec2::new(d.0, d.1) * (len * pen.scale));
+                }
+                continue;
+            }
+            match code {
+                0 => {
+                    if draw {
+                        return;
+                    }
+                }
+                1 => {
+                    if draw {
+                        pen.down = true;
+                    }
+                }
+                2 => {
+                    if draw {
+                        pen.lift();
+                        pen.down = false;
+                    }
+                }
+                3 | 4 => {
+                    let f = f64::from(byte(i).unwrap_or(1).max(1));
+                    i += 1;
+                    if draw {
+                        pen.scale = if code == 3 { pen.scale / f } else { pen.scale * f };
+                    }
+                }
+                5 => {
+                    if draw && pen.stack.len() < MAX_STACK {
+                        pen.stack.push(pen.pos);
+                    }
+                }
+                6 => {
+                    if draw && let Some(q) = pen.stack.pop() {
+                        pen.lift();
+                        pen.pos = q;
+                    }
+                }
+                7 => {
+                    let (num, extra) = match self.kind {
+                        Kind::Shapes => (u32::from(byte(i).unwrap_or(0)), 1),
+                        // Little-endian, like every number in a unifont file; a font that wrote
+                        // it high byte first still finds its shape.
+                        Kind::Unifont => {
+                            let (lo, hi) = (byte(i).unwrap_or(0), byte(i + 1).unwrap_or(0));
+                            let le = u32::from(u16::from_le_bytes([lo, hi]));
+                            let be = u32::from(u16::from_be_bytes([lo, hi]));
+                            (if self.shapes.contains_key(&le) || !self.shapes.contains_key(&be) { le } else { be }, 2)
+                        }
+                        Kind::Bigfont => match byte(i) {
+                            Some(0) => (u32::from(u16::from_be_bytes([byte(i + 1).unwrap_or(0), byte(i + 2).unwrap_or(0)])), 7),
+                            _ => (u32::from(byte(i).unwrap_or(0)), 1),
+                        },
+                    };
+                    i += extra;
+                    if draw
+                        && depth < MAX_DEPTH
+                        && let Some(sub) = self.shapes.get(&num)
+                    {
+                        let saved = pen.scale;
+                        self.run(sub, pen, depth + 1);
+                        pen.scale = saved;
+                    }
+                }
+                8 => {
+                    let (dx, dy) = (sbyte(i).unwrap_or(0.0), sbyte(i + 1).unwrap_or(0.0));
+                    i += 2;
+                    if draw {
+                        pen.move_by(Vec2::new(dx, dy) * pen.scale);
+                    }
+                }
+                9 => loop {
+                    let (dx, dy) = (sbyte(i).unwrap_or(0.0), sbyte(i + 1).unwrap_or(0.0));
+                    i += 2;
+                    if (dx == 0.0 && dy == 0.0) || i > p.len() {
+                        break;
+                    }
+                    if draw {
+                        pen.move_by(Vec2::new(dx, dy) * pen.scale);
+                    }
+                },
+                10 => {
+                    let r = f64::from(byte(i).unwrap_or(0)) * pen.scale;
+                    let oct = byte(i + 1).unwrap_or(0);
+                    i += 2;
+                    if draw {
+                        let (cw, start, n) = octants(oct);
+                        let a0 = start * 45.0;
+                        let sweep = n * 45.0;
+                        pen.arc(r, a0, if cw { a0 - sweep } else { a0 + sweep });
+                    }
+                }
+                11 => {
+                    let (so, eo) = (f64::from(byte(i).unwrap_or(0)), f64::from(byte(i + 1).unwrap_or(0)));
+                    let r = f64::from(u16::from_be_bytes([byte(i + 2).unwrap_or(0), byte(i + 3).unwrap_or(0)])) * pen.scale;
+                    let oct = byte(i + 4).unwrap_or(0);
+                    i += 5;
+                    if draw {
+                        let (cw, start, n) = octants(oct);
+                        let (so, eo) = (so * 45.0 / 256.0, eo * 45.0 / 256.0);
+                        let (a0, a1) = if cw {
+                            let a0 = start * 45.0 - so;
+                            let last = start - (n - 1.0);
+                            (a0, if eo > 0.0 { last * 45.0 - eo } else { (last - 1.0) * 45.0 })
+                        } else {
+                            let a0 = start * 45.0 + so;
+                            let last = start + (n - 1.0);
+                            (a0, if eo > 0.0 { last * 45.0 + eo } else { (last + 1.0) * 45.0 })
+                        };
+                        pen.arc(r, a0, a1);
+                    }
+                }
+                12 => {
+                    let (dx, dy, bu) = (sbyte(i).unwrap_or(0.0), sbyte(i + 1).unwrap_or(0.0), sbyte(i + 2).unwrap_or(0.0));
+                    i += 3;
+                    if draw {
+                        pen.bulge(Vec2::new(dx, dy) * pen.scale, bu / 127.0);
+                    }
+                }
+                13 => loop {
+                    let (dx, dy) = (sbyte(i).unwrap_or(0.0), sbyte(i + 1).unwrap_or(0.0));
+                    i += 2;
+                    if (dx == 0.0 && dy == 0.0) || i > p.len() {
+                        break;
+                    }
+                    let bu = sbyte(i).unwrap_or(0.0);
+                    i += 1;
+                    if draw {
+                        pen.bulge(Vec2::new(dx, dy) * pen.scale, bu / 127.0);
+                    }
+                },
+                14 => skip = true,
+                _ => {}
+            }
+        }
+    }
+}
+
+/// The 16 vector directions, counter-clockwise from east, as unit-square steps.
+const DIRS: [(f64, f64); 16] = [
+    (1.0, 0.0),
+    (1.0, 0.5),
+    (1.0, 1.0),
+    (0.5, 1.0),
+    (0.0, 1.0),
+    (-0.5, 1.0),
+    (-1.0, 1.0),
+    (-1.0, 0.5),
+    (-1.0, 0.0),
+    (-1.0, -0.5),
+    (-1.0, -1.0),
+    (-0.5, -1.0),
+    (0.0, -1.0),
+    (0.5, -1.0),
+    (1.0, -1.0),
+    (1.0, -0.5),
+];
+
+/// An octant byte: clockwise (sign bit), the starting octant (bits 4–6) and the number of
+/// octants (bits 0–3, 0 meaning all 8).
+fn octants(b: u8) -> (bool, f64, f64) {
+    let cw = b & 0x80 != 0;
+    let start = f64::from((b >> 4) & 0x07);
+    let n = match b & 0x0f {
+        0 => 8.0,
+        n => f64::from(n.min(8)),
+    };
+    (cw, start, n)
+}
+
+struct Pen {
+    pos: Vec2,
+    down: bool,
+    scale: f64,
+    stack: Vec<Vec2>,
+    strokes: Vec<Vec<Vec2>>,
+    cur: Vec<Vec2>,
+    steps: usize,
+    points: usize,
+}
+
+impl Pen {
+    fn lift(&mut self) {
+        let cur = std::mem::take(&mut self.cur);
+        if cur.len() >= 2 {
+            self.strokes.push(cur);
+        } else if let Some(p) = cur.first()
+            && self.down
+        {
+            // A dot: drawn as a tiny stroke.
+            self.strokes.push(vec![*p, *p + Vec2::new(0.25, 0.0)]);
+        }
+    }
+    fn line_to(&mut self, q: Vec2) {
+        if !q.is_finite() {
+            return;
+        }
+        if self.down {
+            if self.cur.is_empty() {
+                self.cur.push(self.pos);
+            }
+            self.cur.push(q);
+            self.points += 1;
+        }
+        self.pos = q;
+    }
+    fn move_by(&mut self, d: Vec2) {
+        let q = self.pos + d;
+        if !q.is_finite() {
+            return;
+        }
+        if self.down {
+            self.line_to(q);
+        } else {
+            self.pos = q;
+        }
+    }
+    /// An arc of radius `r` from angle `a0` to `a1` (degrees; the pen is on the circle at `a0`).
+    fn arc(&mut self, r: f64, a0: f64, a1: f64) {
+        if !(r.is_finite() && r > 0.0) {
+            return;
+        }
+        let (s, e) = (a0.to_radians(), a1.to_radians());
+        let center = self.pos - Vec2::from_angle(s) * r;
+        let n = (((e - s).abs() / (std::f64::consts::PI / 16.0)).ceil() as usize).clamp(1, 64);
+        for k in 1..=n {
+            let a = s + (e - s) * k as f64 / n as f64;
+            let q = center + Vec2::from_angle(a) * r;
+            if self.down {
+                self.line_to(q);
+            } else if q.is_finite() {
+                self.pos = q;
+            }
+        }
+    }
+    /// An arc over chord `d` whose height is `bulge` × half the chord (a bulge of ±1 is a half
+    /// circle, positive counter-clockwise); 0 is a straight line.
+    fn bulge(&mut self, d: Vec2, bulge: f64) {
+        let start = self.pos;
+        let end = start + d;
+        if bulge.abs() < 1e-9 || d.len() < 1e-12 {
+            self.move_by(d);
+            return;
+        }
+        let theta = 4.0 * bulge.atan();
+        let chord = d.len();
+        let r = chord / (2.0 * (theta / 2.0).sin()).abs();
+        let mid = start.mid(end);
+        let h = r * (theta / 2.0).cos();
+        let n = d.perp() / chord;
+        let center = mid + n * (h * theta.signum());
+        let (a0, a1) = ((start - center).angle(), (start - center).angle() + theta);
+        let segs = ((theta.abs() / (std::f64::consts::PI / 16.0)).ceil() as usize).clamp(1, 64);
+        for k in 1..=segs {
+            let q = if k == segs { end } else { center + Vec2::from_angle(a0 + (a1 - a0) * k as f64 / segs as f64) * r.abs() };
+            if self.down {
+                self.line_to(q);
+            } else if q.is_finite() {
+                self.pos = q;
+            }
+        }
+    }
+}
+
+// --- Finding fonts -------------------------------------------------------------------------
+
+#[derive(Default)]
+struct Registry {
+    generation: Option<u64>,
+    /// `.shx` files in the user's font folders, by lower-case stem.
+    files: HashMap<String, PathBuf>,
+    /// Fonts registered by name (tests, web builds).
+    bytes: HashMap<String, Arc<Vec<u8>>>,
+    loaded: HashMap<String, Option<Arc<ShxFont>>>,
+}
+
+fn registry() -> MutexGuard<'static, Registry> {
+    static R: OnceLock<Mutex<Registry>> = OnceLock::new();
+    R.get_or_init(|| Mutex::new(Registry::default())).lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+/// The lower-case file stem of a style font name: `C:\13\FONTS\HEBTXT.SHX` → `hebtxt`.
+pub fn stem(name: &str) -> String {
+    let n = name.trim();
+    let file = n.rsplit(['\\', '/']).next().unwrap_or(n);
+    let lower = file.to_ascii_lowercase();
+    lower.strip_suffix(".shx").unwrap_or(&lower).trim().to_string()
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn scan(dirs: &[String]) -> HashMap<String, PathBuf> {
+    let mut out = HashMap::new();
+    // A folder listed earlier wins.
+    let mut stack: Vec<PathBuf> = dirs.iter().rev().map(PathBuf::from).collect();
+    let mut seen = 0usize;
+    while let Some(d) = stack.pop() {
+        let Ok(rd) = std::fs::read_dir(&d) else { continue };
+        let mut sub = Vec::new();
+        for e in rd.flatten() {
+            seen += 1;
+            if seen > 50_000 {
+                return out;
+            }
+            let p = e.path();
+            // Real folders only: a link to a folder could lead in a circle.
+            let Ok(ft) = e.file_type() else { continue };
+            if ft.is_dir() {
+                sub.push(p);
+                continue;
+            }
+            if ft.is_symlink() && p.is_dir() {
+                continue;
+            }
+            let is_shx = p.extension().is_some_and(|x| x.eq_ignore_ascii_case("shx"));
+            if is_shx && let Some(s) = p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()) {
+                out.entry(s).or_insert(p);
+            }
+        }
+        stack.extend(sub.into_iter().rev());
+    }
+    out
+}
+
+#[cfg(target_arch = "wasm32")]
+fn scan(_dirs: &[String]) -> HashMap<String, PathBuf> {
+    HashMap::new()
+}
+
+/// Register SHX bytes under a font name (tests, web builds).
+pub fn register(name: &str, bytes: Vec<u8>) {
+    let mut r = registry();
+    let k = stem(name);
+    r.loaded.remove(&k);
+    r.bytes.insert(k, Arc::new(bytes));
+}
+
+/// The SHX font a style names (`romans_s.shx`, `miryl`, a full path), from the user's font
+/// folders ([`crate::ttf::set_font_dirs`]); `None` when there is none.
+pub fn find(name: &str) -> Option<Arc<ShxFont>> {
+    let k = stem(name);
+    if k.is_empty() {
+        return None;
+    }
+    let mut r = registry();
+    let generation = crate::ttf::generation();
+    if r.generation != Some(generation) {
+        r.files = scan(&crate::ttf::font_dirs());
+        r.loaded.clear();
+        r.generation = Some(generation);
+    }
+    if let Some(f) = r.loaded.get(&k) {
+        return f.clone();
+    }
+    let bytes: Option<Arc<Vec<u8>>> = match r.bytes.get(&k) {
+        Some(b) => Some(b.clone()),
+        None => r.files.get(&k).and_then(|p| {
+            #[cfg(not(target_arch = "wasm32"))]
+            {
+                (std::fs::metadata(p).ok()?.len() <= MAX_FILE_BYTES).then(|| std::fs::read(p).ok()).flatten().map(Arc::new)
+            }
+            #[cfg(target_arch = "wasm32")]
+            {
+                let _ = p;
+                None
+            }
+        }),
+    };
+    let font = bytes.and_then(|b| ShxFont::parse(&k, &b)).map(Arc::new);
+    if r.loaded.len() > 256 {
+        r.loaded.clear();
+    }
+    r.loaded.insert(k, font.clone());
+    font
+}
+
+/// SHX fonts in the user's font folders, by file stem (for font lists).
+pub fn installed() -> Vec<String> {
+    let mut r = registry();
+    let generation = crate::ttf::generation();
+    if r.generation != Some(generation) {
+        r.files = scan(&crate::ttf::font_dirs());
+        r.loaded.clear();
+        r.generation = Some(generation);
+    }
+    let mut v: Vec<String> = r.files.keys().chain(r.bytes.keys()).cloned().collect();
+    v.sort();
+    v.dedup();
+    v
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// A tiny `shapes 1.0` font of our own: shape 0 (above 4, below 1), `A` an up-and-over
+    /// path, `B` with an octant arc, `C` using a sub-shape.
+    pub(crate) fn sample() -> Vec<u8> {
+        let mut b = b"AutoCAD-86 shapes 1.0\r\n\x1a".to_vec();
+        let shapes: Vec<(u16, Vec<u8>)> = vec![
+            (0, [b"T\0".to_vec(), vec![4, 1, 0, 0]].concat()),
+            // A: up 4 (0x44), right 2 (0x20), down 4 (0x4C), pen up, right 1 (0x10), end.
+            (u16::from(b'A'), [b"A\0".to_vec(), vec![0x44, 0x20, 0x4C, 2, 0x10, 0]].concat()),
+            // B: pen up, right 4, pen down, half circle of radius 2 from 0° CCW (4 octants) back to the
+            // origin, pen up, then 5 to the right.
+            (u16::from(b'B'), [b"B\0".to_vec(), vec![2, 0x40, 1, 10, 2, 0x04, 2, 8, 5, 0, 0]].concat()),
+            // C: sub-shape A, then 8,(1,0).
+            (u16::from(b'C'), [b"C\0".to_vec(), vec![7, b'A', 0]].concat()),
+        ];
+        let first = 0u16;
+        let last = u16::from(b'C');
+        b.extend(first.to_le_bytes());
+        b.extend(last.to_le_bytes());
+        b.extend(u16::try_from(shapes.len()).unwrap_or(0).to_le_bytes());
+        for (n, d) in &shapes {
+            b.extend(n.to_le_bytes());
+            b.extend(u16::try_from(d.len()).unwrap_or(0).to_le_bytes());
+        }
+        for (_, d) in &shapes {
+            b.extend(d);
+        }
+        b
+    }
+
+    /// A tiny `unifont 1.0` font of our own: `A` up 4, `C` sub-shape `A` (code 7 then the shape
+    /// number as two bytes, low byte first).
+    fn unifont_sample() -> Vec<u8> {
+        let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec();
+        let shapes: Vec<(u16, Vec<u8>)> = vec![
+            (u16::from(b'A'), [b"A\0".to_vec(), vec![0x44, 0x20, 0]].concat()),
+            (u16::from(b'C'), [b"C\0".to_vec(), vec![7, b'A', 0, 0]].concat()),
+        ];
+        let info = [b"U\0".to_vec(), vec![4, 1, 0, 0, 0, 0]].concat();
+        b.extend(u32::try_from(shapes.len() + 1).unwrap_or(0).to_le_bytes());
+        b.extend(u16::try_from(info.len()).unwrap_or(0).to_le_bytes());
+        b.extend(&info);
+        for (n, d) in &shapes {
+            b.extend(n.to_le_bytes());
+            b.extend(u16::try_from(d.len()).unwrap_or(0).to_le_bytes());
+            b.extend(d);
+        }
+        b
+    }
+
+    #[test]
+    fn a_unifont_subshape_number_is_little_endian() {
+        let f = ShxFont::parse("u", &unifont_sample()).expect("parses");
+        assert_eq!(f.kind, Kind::Unifont);
+        let c = f.glyph(u32::from(b'C')).expect("C");
+        assert_eq!(c.strokes, f.glyph(u32::from(b'A')).expect("A").strokes, "C draws its sub-shape A");
+        assert_eq!(c.advance, 2.0);
+    }
+
+    #[test]
+    fn reads_a_shapes_font_and_runs_its_programs() {
+        let f = ShxFont::parse("t", &sample()).expect("parses");
+        assert_eq!(f.kind, Kind::Shapes);
+        assert_eq!((f.above, f.below), (4.0, 1.0));
+        let a = f.glyph(u32::from(b'A')).expect("A");
+        assert_eq!(a.strokes, vec![vec![Vec2::new(0.0, 0.0), Vec2::new(0.0, 4.0), Vec2::new(2.0, 4.0), Vec2::new(2.0, 0.0)]]);
+        assert_eq!(a.advance, 3.0);
+        let b = f.glyph(u32::from(b'B')).expect("B");
+        let arc = b.strokes.first().expect("an arc");
+        let top = arc.iter().map(|p| p.y).fold(f64::MIN, f64::max);
+        assert!((top - 2.0).abs() < 1e-9, "a half circle of radius 2 reaches y = 2: {top}");
+        assert!(arc.last().is_some_and(|p| (p.x - 0.0).abs() < 1e-9 && p.y.abs() < 1e-9), "{arc:?}");
+        assert_eq!(f.glyph(u32::from(b'C')).map(|g| g.advance), Some(3.0));
+    }
+
+    #[test]
+    fn text_in_an_shx_style_is_drawn_with_the_font() {
+        register("tiny_test.shx", sample());
+        let font = crate::TextFont::resolve("C:\\FONTS\\TINY_TEST.SHX");
+        assert!(matches!(font, crate::TextFont::Shx { .. }), "{font:?}");
+        // Text height 8 is twice the font's capital height (4): A is 3 units wide, B 5.
+        let sh = crate::shape_line(&font, "AB", 8.0, 1.0, 0.0);
+        assert!((sh.width - 16.0).abs() < 1e-9, "{}", sh.width);
+        let top = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max);
+        assert!((top - 8.0).abs() < 1e-9, "{top}");
+        // A character the font lacks comes from the stroke font.
+        let with_x = crate::shape_line(&font, "AX", 8.0, 1.0, 0.0);
+        assert!(with_x.width > 6.0 && with_x.strokes.len() > 1);
+        assert!(crate::ttf::available("tiny_test.shx"));
+    }
+
+    /// A shapes font with a dot glyph at each of `codes`.
+    fn font_with(codes: impl IntoIterator<Item = u16>) -> ShxFont {
+        let mut shapes: Vec<(u16, Vec<u8>)> = vec![(0, b"H\0\x04\x01\x00\x00".to_vec())];
+        shapes.extend(codes.into_iter().map(|c| (c, b"\0\x14\x00".to_vec())));
+        let mut b = b"AutoCAD-86 shapes 1.0\r\n\x1a".to_vec();
+        b.extend(0u16.to_le_bytes());
+        b.extend(shapes.last().map_or(0, |s| s.0).to_le_bytes());
+        b.extend(u16::try_from(shapes.len()).unwrap_or(0).to_le_bytes());
+        for (n, d) in &shapes {
+            b.extend(n.to_le_bytes());
+            b.extend(u16::try_from(d.len()).unwrap_or(0).to_le_bytes());
+        }
+        for (_, d) in &shapes {
+            b.extend(d);
+        }
+        ShxFont::parse("h", &b).expect("parses")
+    }
+
+    #[test]
+    fn hebrew_letters_find_their_one_byte_positions() {
+        // Code page 1255: the 27 letters at 0xE0..0xFA.
+        let f = font_with((0xE0..=0xFA).filter(|c| *c != 0xE1));
+        assert_eq!(f.code_for('א'), Some(0xE0));
+        assert_eq!(f.code_for('ב'), None);
+        // Code page 862: the letters at 0x80..0x9A.
+        let f = font_with(0x80..=0x9A);
+        assert_eq!(f.code_for('א'), Some(0x80));
+        // A Cyrillic-style font (0xC0..0xFF all drawn) is not taken for Hebrew.
+        let f = font_with(0xC0..=0xFF);
+        assert_eq!(f.code_for('א'), None);
+    }
+
+    /// A big font whose records all point at the same large body is read with a bounded copy,
+    /// and a character beyond one byte is not looked up in it by its Unicode code point (#2316
+    /// review).
+    #[test]
+    fn big_fonts_are_bounded_and_not_read_by_unicode() {
+        let mut b = b"AutoCAD-86 bigfont 1.0\r\n\x1a".to_vec();
+        let count: u16 = 60_000;
+        b.extend(count.to_le_bytes());
+        b.extend(0u16.to_le_bytes());
+        let records = b.len() + usize::from(count) * 8;
+        let body_len: u16 = 60_000;
+        for n in 0..count {
+            b.extend(n.wrapping_add(1).to_le_bytes());
+            b.extend(body_len.to_le_bytes());
+            b.extend(u32::try_from(records).unwrap_or(0).to_le_bytes());
+        }
+        // One shared body: a name, then a short program.
+        b.extend(b"X\0\x14\0");
+        b.resize(records + usize::from(body_len), 0);
+        let f = ShxFont::parse("big", &b).expect("a big font");
+        let held: usize = f.shapes.values().map(Vec::len).sum();
+        assert!(held <= (MAX_FILE_BYTES as usize) + usize::from(body_len), "{held} bytes held");
+        // 中 (U+4E2D) is not a double-byte code of the font's code page: not looked up.
+        assert!(f.has(1));
+        assert_eq!(f.code_for('中'), None);
+    }
+
+    #[test]
+    fn hostile_bytes_never_panic() {
+        let good = sample();
+        for cut in 0..good.len() {
+            let _ = ShxFont::parse("x", good.get(..cut).unwrap_or_default());
+        }
+        let mut bad = good.clone();
+        for i in 24..bad.len() {
+            if let Some(b) = bad.get_mut(i) {
+                *b = b.wrapping_mul(37).wrapping_add(11);
+            }
+            if let Some(f) = ShxFont::parse("x", &bad) {
+                for c in 0..300 {
+                    let _ = f.glyph(c);
+                }
+            }
+        }
+        // A shape that calls itself stops.
+        let mut b = b"AutoCAD-86 shapes 1.0\r\n\x1a".to_vec();
+        let shapes: Vec<(u16, Vec<u8>)> = vec![(0, b"T\0\x04\x01\x00\x00".to_vec()), (65, b"\0\x07\x41\x10\x00".to_vec())];
+        b.extend(0u16.to_le_bytes());
+        b.extend(65u16.to_le_bytes());
+        b.extend(2u16.to_le_bytes());
+        for (n, d) in &shapes {
+            b.extend(n.to_le_bytes());
+            b.extend(u16::try_from(d.len()).unwrap_or(0).to_le_bytes());
+        }
+        for (_, d) in &shapes {
+            b.extend(d);
+        }
+        let f = ShxFont::parse("loop", &b).expect("parses");
+        assert!(f.glyph(65).is_some());
+    }
+}
+
+#[cfg(test)]
+mod survey {
+    /// `CADCRAFT_SHX_DIR=… cargo test -p cadcraft-fonts survey -- --ignored --nocapture`: how each
+    /// font of a folder is read (a look at real fonts; not run normally).
+    #[test]
+    #[ignore = "needs a folder of fonts"]
+    fn survey_fonts() {
+        let Ok(dir) = std::env::var("CADCRAFT_SHX_DIR") else { return };
+        let mut v: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).collect();
+        v.sort();
+        for p in v {
+            let b = std::fs::read(&p).unwrap();
+            let name = p.file_name().unwrap().to_string_lossy().to_string();
+            match super::ShxFont::parse(&name, &b) {
+                Some(f) => println!(
+                    "{name}: {:?} 1255={} 862={} E0..FA={} C0..DF={} 80..9A={} A0..AF={} U+05D0={} U+0430={} E0~430={}",
+                    f.kind,
+                    f.hebrew_1255(),
+                    f.hebrew_862(),
+                    f.count(0xE0..=0xFA),
+                    f.count(0xC0..=0xDF),
+                    f.count(0x80..=0x9A),
+                    f.count(0xA0..=0xAF),
+                    f.has(0x05D0),
+                    f.has(0x0430),
+                    f.glyph(0xE0).map(|g| g.strokes) == f.glyph(0x430).map(|g| g.strokes)
+                ),
+                None => println!("{name}: unreadable"),
+            }
+        }
+    }
+}
diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs
--- a/crates/fonts/src/ttf.rs
+++ b/crates/fonts/src/ttf.rs
@@ -13,6 +13,10 @@
 //!
 //! On native builds both start from the `CADCRAFT_FONTALT` / `CADCRAFT_FONTFALLBACK`
 //! environment variables.
+//!
+//! Besides the system font folders, the user's own font folders ([`set_font_dirs`], Options ▸
+//! Fonts) are searched first: a font placed there wins over an installed one with the same file
+//! name.
 
 use std::collections::HashMap;
 use std::path::PathBuf;
@@ -75,6 +79,16 @@ impl Face {
     pub fn has_glyph(&self, c: char) -> bool {
         self.font().is_some_and(|f| f.charmap().map(c).is_some())
     }
+    /// The family name, as the font gives it.
+    pub fn family_name(&self) -> Option<String> {
+        let f = self.font()?;
+        [skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FAMILY_NAME]
+            .into_iter()
+            .find_map(|id| f.localized_strings(id).next().map(|s| s.to_string()))
+            .map(|s| s.chars().filter(|c| !c.is_control()).take(80).collect::<String>())
+            .filter(|s| !s.trim().is_empty())
+    }
+
     fn names(&self) -> Vec<String> {
         let Some(f) = self.font() else { return Vec::new() };
         [skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME]
@@ -182,6 +196,20 @@ struct Db {
     fallback: Fallback,
     /// Bytes of font files loaded from disk so far (bounded by `MAX_LOADED_BYTES`).
     loaded_bytes: usize,
+    /// Stems of the fonts found in the user's font folders.
+    user: std::collections::HashSet<String>,
+    /// Names read from user font files for lists and family searches, by stem. Reading them
+    /// does not keep the file's bytes, so it does not use up `MAX_LOADED_BYTES`.
+    face_names: HashMap<String, Arc<FaceNames>>,
+}
+
+/// The names inside one font file.
+#[derive(Default)]
+struct FaceNames {
+    /// The first face's family name.
+    family: Option<String>,
+    /// Each face's normalized names (family, typographic family, full name).
+    faces: Vec<Vec<String>>,
 }
 
 /// State of the missing-glyph search.
@@ -212,10 +240,18 @@ fn lock() -> MutexGuard<'static, Db> {
     let mut g = DB.get_or_init(|| Mutex::new(Db::default())).lock().unwrap_or_else(PoisonError::into_inner);
     if !g.scanned {
         g.scanned = true;
-        g.paths = scan().into_iter().map(|(stem, p)| (stem, Source::File(p))).collect();
-        // Embedded craft-fonts fonts take the place of an installed file with the same stem.
+        let user = scan_dirs(&user_dirs(), true);
+        g.user = user.keys().cloned().collect();
+        let mut all = scan();
+        all.extend(user);
+        g.paths = all.into_iter().map(|(stem, p)| (stem, Source::File(p))).collect();
+        // Embedded craft-fonts fonts take the place of an installed file with the same stem,
+        // but not of a font in the user's own folders (which they chose to use).
         for f in crate::CRAFT_FONTS {
-            g.paths.insert(f.stem(), Source::Static(f.bytes));
+            let stem = f.stem();
+            if !g.user.contains(&stem) {
+                g.paths.insert(stem, Source::Static(f.bytes));
+            }
         }
     }
     g
@@ -223,7 +259,6 @@ fn lock() -> MutexGuard<'static, Db> {
 
 #[cfg(not(target_arch = "wasm32"))]
 fn scan() -> HashMap<String, PathBuf> {
-    let mut out = HashMap::new();
     let mut dirs: Vec<PathBuf> = Vec::new();
     if cfg!(target_os = "macos") {
         dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
@@ -242,7 +277,15 @@ fn scan() -> HashMap<String, PathBuf> {
             dirs.push(PathBuf::from(h).join(".local/share/fonts"));
         }
     }
-    let mut stack = dirs;
+    scan_dirs(&dirs, false)
+}
+
+/// Font files under `dirs` by lower-case stem; the first file found with a stem wins
+/// (`in_order`: a folder listed earlier wins over a later one).
+#[cfg(not(target_arch = "wasm32"))]
+fn scan_dirs(dirs: &[PathBuf], in_order: bool) -> HashMap<String, PathBuf> {
+    let mut out = HashMap::new();
+    let mut stack: Vec<PathBuf> = if in_order { dirs.iter().rev().cloned().collect() } else { dirs.to_vec() };
     let mut seen = 0usize;
     while let Some(d) = stack.pop() {
         let Ok(rd) = std::fs::read_dir(&d) else { continue };
@@ -252,10 +295,15 @@ fn scan() -> HashMap<String, PathBuf> {
                 return out;
             }
             let p = e.path();
-            if p.is_dir() {
+            // Real folders only: a link to a folder could lead in a circle.
+            let Ok(ft) = e.file_type() else { continue };
+            if ft.is_dir() {
                 stack.push(p);
                 continue;
             }
+            if ft.is_symlink() && p.is_dir() {
+                continue;
+            }
             let ext = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
             if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
                 continue;
@@ -273,6 +321,138 @@ fn scan() -> HashMap<String, PathBuf> {
     HashMap::new()
 }
 
+#[cfg(target_arch = "wasm32")]
+fn scan_dirs(_dirs: &[PathBuf], _in_order: bool) -> HashMap<String, PathBuf> {
+    HashMap::new()
+}
+
+/// Most user font folders.
+pub const MAX_FONT_DIRS: usize = 16;
+/// The longest font folder path kept.
+const MAX_DIR_CHARS: usize = 1024;
+
+fn dirs_lock() -> MutexGuard<'static, Vec<PathBuf>> {
+    static DIRS: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();
+    DIRS.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+fn user_dirs() -> Vec<PathBuf> {
+    dirs_lock().clone()
+}
+
+/// Counts changes to the set of fonts ([`set_font_dirs`], [`rescan`]): text drawn before a change
+/// may have been drawn with another font.
+static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
+/// The font set's change count (see [`GENERATION`]).
+pub fn generation() -> u64 {
+    GENERATION.load(std::sync::atomic::Ordering::Relaxed)
+}
+
+/// The user's font folders, searched before the system's.
+pub fn font_dirs() -> Vec<String> {
+    user_dirs().iter().map(|p| p.to_string_lossy().to_string()).collect()
+}
+
+/// Clean a font folder list: trimmed, without empty, repeated or over-long entries, at most
+/// [`MAX_FONT_DIRS`].
+pub fn clean_dirs<S: AsRef<str>>(dirs: &[S]) -> Vec<String> {
+    let mut out: Vec<String> = Vec::new();
+    for d in dirs.iter().take(MAX_FONT_DIRS * 4) {
+        let d = d.as_ref().trim();
+        if d.is_empty() || d.chars().count() > MAX_DIR_CHARS || d.chars().any(char::is_control) || out.iter().any(|o| o == d) {
+            continue;
+        }
+        out.push(d.to_string());
+        if out.len() >= MAX_FONT_DIRS {
+            break;
+        }
+    }
+    out
+}
+
+/// Set the user's font folders (Options ▸ Fonts) and look for fonts again.
+pub fn set_font_dirs<S: AsRef<str>>(dirs: &[S]) {
+    let dirs: Vec<PathBuf> = clean_dirs(dirs).into_iter().map(PathBuf::from).collect();
+    {
+        let mut current = dirs_lock();
+        if *current == dirs {
+            return;
+        }
+        *current = dirs;
+    }
+    rescan();
+}
+
+/// Forget what was found and look for fonts again (files added to a font folder).
+pub fn rescan() {
+    let mut g = lock();
+    // Fonts registered by name (no file behind them) stay.
+    let paths = std::mem::take(&mut g.paths);
+    g.files.retain(|k, _| !paths.contains_key(k));
+    g.scanned = false;
+    g.resolved.clear();
+    g.fallback = Fallback::default();
+    g.loaded_bytes = 0;
+    g.user.clear();
+    g.face_names.clear();
+    drop(g);
+    GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+}
+
+/// A font file CADCraft can use, for font lists.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FontFile {
+    /// The name a text style stores: the file name (`arial.ttf`), or the name of a font built
+    /// into CADCraft.
+    pub name: String,
+    /// The family name inside the file, read for fonts in the user's folders.
+    pub family: Option<String>,
+    /// Found in one of the user's font folders.
+    pub user: bool,
+}
+
+/// The fonts CADCraft can use: those in the user's folders first (with their family names),
+/// then the installed and built-in ones, by name.
+pub fn installed() -> Vec<FontFile> {
+    /// Fonts whose family name is read for the list.
+    const MAX_NAMED: usize = 200;
+    let mut g = lock();
+    let mut list: Vec<(String, String, bool)> = g
+        .paths
+        .iter()
+        .map(|(stem, src)| {
+            let name = match src {
+                Source::File(p) => p.file_name().map_or_else(|| stem.clone(), |n| n.to_string_lossy().to_string()),
+                Source::Static(_) => stem.clone(),
+            };
+            (stem.clone(), name, g.user.contains(stem))
+        })
+        .collect();
+    list.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase())));
+    let mut named = 0usize;
+    list.into_iter()
+        .map(|(stem, name, user)| {
+            let family = if user && named < MAX_NAMED {
+                named += 1;
+                g.face_names(&stem).and_then(|n| n.family.clone())
+            } else {
+                None
+            };
+            FontFile { name, family, user }
+        })
+        .collect()
+}
+
+/// Whether a text style font is drawn as named: a font CADCraft found, or its own stroke font.
+/// Other names (an SHX file, a missing TTF) are drawn with a substitute ([`crate::TextFont::resolve`]).
+pub fn available(name: &str) -> bool {
+    let k = key(name);
+    // The built-in stroke font is CADCraft's own stand-in for the basic stroke fonts.
+    let stand_in = matches!(k.trim_end_matches(".shx"), "txt" | "simplex" | "romans");
+    k.is_empty() || stand_in || k == crate::BUILTIN_FONT.to_ascii_lowercase() || crate::shx::find(name).is_some() || find(name).is_some()
+}
+
 #[cfg(not(target_arch = "wasm32"))]
 fn read_file(path: &std::path::Path) -> Option<Vec<u8>> {
     /// Font files larger than this are never read.
@@ -297,15 +477,35 @@ impl Db {
         if self.loaded_bytes >= MAX_LOADED_BYTES {
             return None;
         }
-        let bytes = Arc::new(match self.paths.get(stem)? {
-            Source::File(path) => read_file(path)?,
-            Source::Static(bytes) => bytes.to_vec(),
-        });
+        let bytes = self.peek(stem)?;
         self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len());
         self.files.insert(stem.to_string(), bytes.clone());
         Some(bytes)
     }
 
+    /// Bytes of the font file with this stem, without keeping them or charging the load budget.
+    fn peek(&self, stem: &str) -> Option<Arc<Vec<u8>>> {
+        if let Some(b) = self.files.get(stem) {
+            return Some(b.clone());
+        }
+        Some(Arc::new(match self.paths.get(stem)? {
+            Source::File(path) => read_file(path)?,
+            Source::Static(bytes) => bytes.to_vec(),
+        }))
+    }
+
+    /// The names inside the font file with this stem, read once (the bytes are dropped after).
+    fn face_names(&mut self, stem: &str) -> Option<Arc<FaceNames>> {
+        if let Some(n) = self.face_names.get(stem) {
+            return Some(n.clone());
+        }
+        let bytes = self.peek(stem)?;
+        let all = faces(&bytes);
+        let names = Arc::new(FaceNames { family: all.first().and_then(Face::family_name), faces: all.iter().map(Face::names).collect() });
+        self.face_names.insert(stem.to_string(), names.clone());
+        Some(names)
+    }
+
     fn lookup(&mut self, k: &str) -> Option<Face> {
         let candidates = [k.to_string(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))];
         for c in &candidates {
@@ -313,7 +513,26 @@ impl Db {
                 return Some(face);
             }
         }
-        self.by_family(k)
+        self.by_family(k).or_else(|| self.user_family(k))
+    }
+
+    /// A face in the user's font folders whose family or full name is `k`, whatever its file is
+    /// called (the folders are small; at most 200 files are looked at).
+    fn user_family(&mut self, k: &str) -> Option<Face> {
+        let want = norm(k);
+        if want.is_empty() {
+            return None;
+        }
+        let mut stems: Vec<String> = self.user.iter().cloned().collect();
+        stems.sort();
+        for stem in stems.into_iter().take(200) {
+            let Some(names) = self.face_names(&stem) else { continue };
+            let Some(i) = names.faces.iter().position(|f| f.iter().any(|n| *n == want || format!("{n}regular") == want)) else { continue };
+            // Only the file that has the face is kept (and charged to the load budget).
+            let bytes = self.file(&stem).or_else(|| self.peek(&stem))?;
+            return faces(&bytes).into_iter().nth(i);
+        }
+        None
     }
 
     /// A face whose family or full name is `k`, among files with a similar stem, then among the
@@ -922,6 +1141,45 @@ pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique
 mod family_tests {
     use super::*;
 
+    #[test]
+    fn font_folders_are_cleaned_and_unknown_shx_names_are_not_available() {
+        let long = "x".repeat(MAX_DIR_CHARS + 1);
+        let many: Vec<String> = (0..40).map(|i| format!("/fonts/{i}")).collect();
+        assert_eq!(clean_dirs(&["  /a ", "", "/a", "/b", long.as_str(), "bad\u{0}dir"]), vec!["/a".to_string(), "/b".to_string()]);
+        assert_eq!(clean_dirs(&many).len(), MAX_FONT_DIRS);
+        assert!(!available("gost_cadcraft.shx"));
+        assert!(available("txt.shx") && available("SIMPLEX.SHX"), "the built-in stroke font stands in for these");
+        assert!(available(""));
+        assert!(available(crate::BUILTIN_FONT));
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    #[test]
+    fn a_font_in_a_user_folder_is_found_listed_and_dropped_with_the_folder() {
+        let Some(src) = scan().into_values().find(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("ttf"))) else { return };
+        let dir = std::env::temp_dir().join(format!("cadcraft-fontdir-{}", std::process::id()));
+        let _ = std::fs::create_dir_all(&dir);
+        let Ok(_) = std::fs::copy(&src, dir.join("CadcraftTestFace.ttf")) else { return };
+        let before = generation();
+        set_font_dirs(&[dir.to_string_lossy().to_string()]);
+        assert!(generation() > before);
+        // Listing reads the family name without keeping the file (or using up the load budget).
+        let early = installed();
+        assert!(early.iter().any(|f| f.name == "CadcraftTestFace.ttf" && f.family.is_some()));
+        assert!(!lock().files.contains_key("cadcrafttestface"), "listing keeps no font bytes");
+        assert!(find("CadcraftTestFace.ttf").is_some());
+        let listed = installed();
+        let entry = listed.iter().find(|f| f.name == "CadcraftTestFace.ttf");
+        assert!(entry.is_some_and(|f| f.user && f.family.is_some()), "{entry:?}");
+        assert!(listed.first().is_some_and(|f| f.user), "the user's fonts come first");
+        if let Some(family) = entry.and_then(|f| f.family.clone()) {
+            assert!(find(&family).is_some(), "found by its family name: {family}");
+        }
+        set_font_dirs::<&str>(&[]);
+        assert!(find("CadcraftTestFace.ttf").is_none());
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
     #[test]
     fn a_family_is_looked_for_in_files_starting_with_its_first_word() {
         let stems: Vec<String> = ["gost", "gost2304a", "gost common", "arial", "isocpeur"].map(String::from).to_vec();
diff --git a/crates/render/src/dim_draw.rs b/crates/render/src/dim_draw.rs
--- a/crates/render/src/dim_draw.rs
+++ b/crates/render/src/dim_draw.rs
@@ -7,7 +7,12 @@ use super::*;
 /// The font settings of a dimension style's text style.
 pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText {
     let ts = text_style(d, &st.text_style);
-    DimText { font: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique }
+    DimText {
+        font: cadcraft_fonts::TextFont::resolve_style(&ts.font, &ts.big_font),
+        fixed_height: ts.height,
+        width_factor: ts.width_factor,
+        oblique: ts.oblique,
+    }
 }
 
 /// Geometry of a dimension in a drawing: its style, overrides and text font.
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -34,6 +34,7 @@ pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, d
 pub use fill::triangulate_evenodd;
 pub use hatch::{fill_loops as hatch_fill_loops, pattern_swatch};
 pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
+pub use text::triangulate_placed as triangulate_glyph;
 pub use tolerance::{gdt_points, tolerance_in};
 
 /// A resolved colour: its RGB, whether it is colour 7, and its index colour (0 = a true
@@ -235,7 +236,7 @@ impl Builder<'_> {
                     self.tris_at(ctx, color, &mesh.triangles, xf);
                     mesh.triangles.len()
                 } else {
-                    let tris = fill::triangulate_evenodd(g);
+                    let tris = crate::text::triangulate_placed(g);
                     self.tris(ctx, color, &tris);
                     tris.len()
                 };
diff --git a/crates/render/src/linetype_text.rs b/crates/render/src/linetype_text.rs
--- a/crates/render/src/linetype_text.rs
+++ b/crates/render/src/linetype_text.rs
@@ -83,7 +83,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
         if !(height.is_finite() && height > 0.0) {
             continue;
         }
-        let font = TextFont::resolve(&ts.font);
+        let font = TextFont::resolve_style(&ts.font, &ts.big_font);
         let params = TextParams {
             width_factor: if ts.width_factor.is_finite() && ts.width_factor > 0.0 { ts.width_factor } else { 1.0 },
             oblique: if ts.oblique.is_finite() { ts.oblique } else { 0.0 },
diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs
--- a/crates/render/src/table.rs
+++ b/crates/render/src/table.rs
@@ -375,7 +375,7 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) {
     }
     for tx in &g.texts {
         let ts = crate::text_style(ctx.d, &tx.style);
-        let font = cadcraft_fonts::TextFont::resolve(&ts.font);
+        let font = cadcraft_fonts::TextFont::resolve_style(&ts.font, &ts.big_font);
         // Cell content is MTEXT (`\P`, `{\Q15;…}`, `\F…;`): laid out as such, at the cell's
         // alignment point (its numbering is MTEXT's attachment).
         let mp = cadcraft_fonts::MTextParams {
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -163,10 +163,13 @@ fn simplify(contour: &[Vec2], tolerance: f64) -> Vec<Vec2> {
     if result.len() >= 4 { result } else { contour.to_vec() }
 }
 
-/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32
-/// precision is independent of drawing coordinates; the placement stays f64. The existing
-/// slab filler remains the fallback when tessellation fails (including its u16 vertex limit).
-fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
+/// Glyph contours filled by the non-zero winding rule, as TrueType and OpenType define it: a
+/// glyph drawn from overlapping contours (AutoCAD's TrueType versions of its stroke fonts, many
+/// modern fonts) is solid where they overlap, not hollow as the even-odd rule would leave it.
+/// Work in font units so f32 precision is independent of drawing coordinates; the placement
+/// stays f64. The slab filler (even-odd) remains the fallback when tessellation fails
+/// (including its u16 vertex limit).
+pub(crate) fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
     let mut path = Path::builder();
     for contour in contours {
         let Some(&first) = contour.first().filter(|_| contour.len() >= 3) else { continue };
@@ -179,7 +182,7 @@ fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
     let mut buffers: VertexBuffers<Vec2, u16> = VertexBuffers::new();
     let result = FillTessellator::new().tessellate_path(
         &path.build(),
-        &FillOptions::even_odd(),
+        &FillOptions::non_zero(),
         &mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| {
             let p = v.position();
             Vec2::new(f64::from(p.x), f64::from(p.y))
@@ -190,3 +193,48 @@ fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
     }
     buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect()
 }
+
+/// [`triangulate`] for contours placed in drawing coordinates (glyphs shaped without a font
+/// glyph reference, such as right-to-left runs): tessellated about their own corner so large
+/// coordinates keep their precision.
+pub fn triangulate_placed(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
+    let Some(o) = contours.iter().flatten().copied().find(|p| p.is_finite()) else { return Vec::new() };
+    let local: Vec<Vec<Vec2>> = contours.iter().map(|c| c.iter().map(|p| *p - o).collect()).collect();
+    triangulate(&local).into_iter().map(|p| p + o).collect()
+}
+
+#[cfg(test)]
+mod fill_rule_tests {
+    use cadcraft_geom::Vec2;
+
+    fn area(tris: &[Vec2]) -> f64 {
+        tris.chunks(3)
+            .map(|t| match t {
+                [a, b, c] => ((*b - *a).x * (*c - *a).y - (*b - *a).y * (*c - *a).x).abs() / 2.0,
+                _ => 0.0,
+            })
+            .sum()
+    }
+
+    fn square(x: f64, y: f64, s: f64) -> Vec<Vec2> {
+        vec![Vec2::new(x, y), Vec2::new(x + s, y), Vec2::new(x + s, y + s), Vec2::new(x, y + s)]
+    }
+
+    /// Two overlapping contours drawn the same way round (how many fonts build a glyph): the
+    /// overlap is ink, not a hole.
+    #[test]
+    fn overlapping_glyph_contours_fill_solid() {
+        let a = area(&super::triangulate(&[square(0.0, 0.0, 10.0), square(5.0, 0.0, 10.0)]));
+        assert!((a - 150.0).abs() < 1e-3, "union area, got {a}");
+        // A counter (the hole of an "o") is drawn the other way round and stays a hole.
+        let mut hole = square(2.0, 2.0, 6.0);
+        hole.reverse();
+        let a = area(&super::triangulate(&[square(0.0, 0.0, 10.0), hole]));
+        assert!((a - 64.0).abs() < 1e-3, "ring area, got {a}");
+        // Far from the origin, as right-to-left glyphs are placed.
+        let far: Vec<Vec<Vec2>> =
+            [square(0.0, 0.0, 10.0), square(5.0, 0.0, 10.0)].iter().map(|c| c.iter().map(|p| *p + Vec2::new(-38653.0, 147552.0)).collect()).collect();
+        let a = area(&super::triangulate_placed(&far));
+        assert!((a - 150.0).abs() < 1e-2, "placed union area, got {a}");
+    }
+}
diff --git a/crates/render/src/text_entity.rs b/crates/render/src/text_entity.rs
--- a/crates/render/src/text_entity.rs
+++ b/crates/render/src/text_entity.rs
@@ -8,7 +8,7 @@ pub(crate) fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle {
 /// Shape a TEXT / ATTRIB / ATTDEF with its style's font and generation flags.
 pub fn place_text_entity(d: &Drawing, t: &cadcraft_doc::Text, value: &str) -> (cadcraft_fonts::Shaped, Bounds2) {
     let ts = text_style(d, &t.style);
-    let font = cadcraft_fonts::TextFont::resolve(&ts.font);
+    let font = cadcraft_fonts::TextFont::resolve_style(&ts.font, &ts.big_font);
     let (h, v) = align(t.halign, t.valign);
     let p = cadcraft_fonts::TextParams {
         insert: t.insert.xy(),
@@ -36,7 +36,7 @@ pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fon
         rotation: t.rotation,
         line_spacing: t.line_spacing,
         line_spacing_exact: t.line_spacing_exact,
-        font: cadcraft_fonts::TextFont::resolve(&ts.font),
+        font: cadcraft_fonts::TextFont::resolve_style(&ts.font, &ts.big_font),
         width_factor: ts.width_factor,
         oblique: ts.oblique,
         columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec {
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -75,6 +75,9 @@ fn same_content(a: &ListKey, b: &ListKey) -> bool {
 #[derive(Default)]
 pub struct CanvasState {
     pub list: Option<DisplayList>,
+    /// The font set the list was drawn with ([`cadcraft_fonts::ttf::generation`]): a new font
+    /// folder redraws the text.
+    font_generation: u64,
     key: ListKey,
     /// The area `list` was built for (hatch patterns exist only inside it); a view leaving it
     /// rebuilds the list.
diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs
--- a/crates/ui-egui/src/canvas/list.rs
+++ b/crates/ui-egui/src/canvas/list.rs
@@ -65,6 +65,14 @@ pub(super) fn rebuilt_preview<'a>(doc: &cadcraft_doc::Drawing, ents: impl IntoIt
 /// yet, is built at once.
 pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], ctx: &egui::Context) {
     let px = 1.0 / xf.scale;
+    let fonts = cadcraft_fonts::ttf::generation();
+    if app.canvas.font_generation != fonts {
+        app.canvas.font_generation = fonts;
+        app.canvas.list = None;
+        app.canvas.job = None;
+        // The model space seen through a layout's active viewport is drawn apart: rebuilt too.
+        app.canvas.vp.key = None;
+    }
     let Ok(st) = app.session.state() else { return };
     // Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a
     // view leaves the area the list was built for.
