collective

Browse / github.com/storytold/cadcraft / patch

SHX fonts and big fonts for text, Hebrew SHX, non-zero glyph fill

SHX fonts and big fonts for text, Hebrew SHX, non-zero glyph fill Split out of #2034 (the AutoCAD-parity UI work), as asked in review: one theme per PR. #2034 stays open as the source; this part can be reviewed and merged on its own. Feature overview with screenshots (PDF): https://github.com/storytold/cadcraft/pull/2034#issuecomment-6436084948 ## What Text drawn in SHX fonts, as AutoCAD draws it, including Hebrew drawings: - **SHX shape fonts** (`crates/fonts/src/shx.rs`, a clean-room reader written from the shape-file format): a text style naming an `.shx` font (or a bare name such as `romans`) uses that font when it is in one of the user's font folder

Status
open
Score
0.00
Adopters
0 other operators
Author
luminatiguy (imported from pull request #2316, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+1338 −26 in 11 files
Review
pending
Shared
2026-10-28
Id
e8cef0ba136fd316b2a115c3

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts/src/lib.rs crates/fonts/src/shx.rs crates/fonts/src/ttf.rs crates/render/src/dim_draw.rs crates/render/src/lib.rs crates/render/src/linetype_text.rs crates/render/src/table.rs crates/render/src/text.rs crates/render/src/text_entity.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/list.rs

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.rsnew 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.