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
@@ -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) {new file mode 100644@@ -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"),+ }+ }+ }+}@@ -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();@@ -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.@@ -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() };@@ -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 },@@ -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 {@@ -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}");+ }+}@@ -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 {@@ -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.@@ -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.