feat: sketch text takes a font, an installed family or a font file (#516)

sketch.text and sketch.edit_text take an optional font: an installed family or a .ttf/.otf/.ttc file. Edit Text gets a Font field. The font is kept with the text under format 16; no fonts are bundled, and the font search is bounded.

Fixes #513

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-13 02:04:59 -04:00
1 parent 7d00df0a3f
commit a69c2928f0
15 files changed
+883 -41

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
//! Font choice for sketch text. A small TrueType font is built here, so the outlines it must give are
//! known by hand; the repository's second font (BIZ UDPGothic, `apps/solidcraft-web/fonts`) stands for
//! a real one.
use std::path::PathBuf;
use solidcraft_geom::Vec2;
use crate::fonts::find_family_in;
use crate::tests::v;
use crate::*;
/// One glyph of the test font: its character, advance width and contours (font units, y up; an outer
/// contour runs clockwise, a counter counter-clockwise, as in TrueType).
struct Glyph {
ch: char,
advance: u16,
contours: Vec<Vec<(i16, i16)>>,
}
fn block(x0: i16, y0: i16, x1: i16, y1: i16) -> Vec<(i16, i16)> {
vec![(x0, y0), (x0, y1), (x1, y1), (x1, y0)]
}
fn hole(x0: i16, y0: i16, x1: i16, y1: i16) -> Vec<(i16, i16)> {
vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
}
/// A TrueType font of 1000 units per em with these glyphs (and an empty `.notdef`), named `family`. It has
/// no OS/2 table, so it has no capital height of its own.
fn build_font(family: &str, glyphs: &[Glyph]) -> Vec<u8> {
fn u16s(out: &mut Vec<u8>, values: &[u16]) {
values.iter().for_each(|v| out.extend(v.to_be_bytes()));
}
fn u32s(out: &mut Vec<u8>, values: &[u32]) {
values.iter().for_each(|v| out.extend(v.to_be_bytes()));
}
let count = glyphs.len() as u16 + 1;
// glyf and loca (long offsets).
let (mut glyf, mut loca) = (Vec::new(), vec![0u32]);
for g in std::iter::once(None).chain(glyphs.iter().map(Some)) {
if let Some(g) = g {
let pts: Vec<(i16, i16)> = g.contours.iter().flatten().copied().collect();
let (xs, ys): (Vec<i16>, Vec<i16>) = pts.iter().copied().unzip();
let (x0, x1, y0, y1) = (xs.iter().min(), xs.iter().max(), ys.iter().min(), ys.iter().max());
u16s(&mut glyf, &[g.contours.len() as u16]);
glyf.extend([x0, y0, x1, y1].map(|v| v.copied().unwrap_or(0)).iter().flat_map(|v| v.to_be_bytes()));
let mut end = 0;
for c in &g.contours {
end += c.len() as u16;
u16s(&mut glyf, &[end - 1]);
}
u16s(&mut glyf, &[0]); // no instructions
glyf.extend(vec![1u8; pts.len()]); // on-curve points, 16-bit deltas
for coords in [xs, ys] {
let mut prev = 0;
for v in coords {
glyf.extend((v - prev).to_be_bytes());
prev = v;
}
}
while glyf.len() % 4 != 0 {
glyf.push(0);
}
}
loca.push(glyf.len() as u32);
}
let mut head = Vec::new();
u32s(&mut head, &[0x0001_0000, 0x0001_0000, 0, 0x5F0F_3CF5]);
u16s(&mut head, &[0, 1000]);
head.extend([0u8; 16]); // created, modified
u16s(&mut head, &[0, 0, 1000, 1000, 0, 8, 2, 1, 0]); // box, style, smallest size, direction, long loca, format
let mut hhea = Vec::new();
u32s(&mut hhea, &[0x0001_0000]);
u16s(&mut hhea, &[800, (-200i16) as u16, 0, 1000, 0, 0, 1000, 1, 0, 0, 0, 0, 0, 0, 0, count]);
let mut maxp = Vec::new();
u32s(&mut maxp, &[0x0001_0000]);
u16s(&mut maxp, &[count]);
let mut hmtx = Vec::new();
u16s(&mut hmtx, &[0, 0]);
for g in glyphs {
u16s(&mut hmtx, &[g.advance, 0]);
}
// cmap: one format 12 subtable, a group per character.
let mut chars: Vec<(u32, u32)> = glyphs.iter().enumerate().map(|(i, g)| (g.ch as u32, i as u32 + 1)).collect();
chars.sort();
let mut cmap = Vec::new();
u16s(&mut cmap, &[0, 1, 3, 10]);
u32s(&mut cmap, &[12, 0x000C_0000, 16 + 12 * chars.len() as u32, 0, chars.len() as u32]);
for (c, g) in chars {
u32s(&mut cmap, &[c, c, g]);
}
// name: family (1), style (2), full name (4), all Windows Unicode.
let strings = [(1u16, family.to_string()), (2, "Regular".to_string()), (4, format!("{family} Regular"))];
let (mut name, mut storage) = (Vec::new(), Vec::new());
u16s(&mut name, &[0, strings.len() as u16, 6 + 12 * strings.len() as u16]);
for (id, s) in &strings {
let utf16: Vec<u16> = s.encode_utf16().collect();
u16s(&mut name, &[3, 1, 0x409, *id, 2 * utf16.len() as u16, storage.len() as u16]);
utf16.iter().for_each(|u| storage.extend(u.to_be_bytes()));
}
name.extend(storage);
// The table directory, tables sorted by tag.
let tables: [(&[u8; 4], Vec<u8>); 8] = [
(b"cmap", cmap),
(b"glyf", glyf),
(b"head", head),
(b"hhea", hhea),
(b"hmtx", hmtx),
(b"loca", loca.iter().flat_map(|o| o.to_be_bytes()).collect()),
(b"maxp", maxp),
(b"name", name),
];
let (mut dir, mut body) = (Vec::new(), Vec::new());
u32s(&mut dir, &[0x0001_0000]);
u16s(&mut dir, &[tables.len() as u16, 128, 3, 0]);
for (tag, data) in &tables {
dir.extend(*tag);
u32s(&mut dir, &[0, 12 + 16 * tables.len() as u32 + body.len() as u32, data.len() as u32]);
body.extend(data);
while body.len() % 4 != 0 {
body.push(0);
}
}
dir.extend(body);
dir
}
/// The test font "Tiny Block": H is a 600 x 700 block, I a 100 x 700 bar and O an 800 x 700 block with a
/// 400 x 300 counter at (200, 200), all in font units.
fn tiny_font() -> Vec<u8> {
build_font(
"Tiny Block",
&[
Glyph { ch: 'H', advance: 700, contours: vec![block(0, 0, 600, 700)] },
Glyph { ch: 'I', advance: 200, contours: vec![block(0, 0, 100, 700)] },
Glyph { ch: 'O', advance: 900, contours: vec![block(0, 0, 800, 700), hole(200, 200, 600, 500)] },
],
)
}
/// A folder of the test font and a few things that are not fonts, removed at the end of the test.
struct Folder(PathBuf);
impl Folder {
fn new(name: &str) -> Folder {
let dir = std::env::temp_dir().join(format!("solidcraft-font-tests-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("nested")).unwrap();
std::fs::write(dir.join("nested").join("tiny.ttf"), tiny_font()).unwrap();
std::fs::write(dir.join("notes.txt"), "not a font").unwrap();
std::fs::write(dir.join("broken.ttf"), "not a font either").unwrap();
Folder(dir)
}
fn font(&self) -> String {
self.0.join("nested").join("tiny.ttf").to_string_lossy().into_owned()
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn web_fonts() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("apps").join("solidcraft-web").join("fonts")
}
/// The corner points of the lines of `geometry`, as (min, max) over x and y.
fn extent(geometry: &[LinkGeom]) -> (Vec2, Vec2) {
let pts: Vec<Vec2> = geometry
.iter()
.flat_map(|g| match g {
LinkGeom::Line(a, b) => vec![*a, *b],
other => panic!("only lines here: {other:?}"),
})
.collect();
let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
(lo, hi)
}
#[test]
fn a_font_file_sets_the_outlines_and_the_scale() {
let folder = Folder::new("outlines");
// The capital height is that of the font's H (700 units, no OS/2 table), so 7 mm tall text is 0.01 mm per unit:
// the O is an 8 x 7 mm block (x 10 to 18, y 20 to 27) with a counter from (12, 22) to (16, 25).
let o = text_geometry_in(Some(&folder.font()), "O", v(10.0, 20.0), 7.0, 0.0).unwrap();
assert_eq!(o.len(), 8);
let (lo, hi) = extent(&o);
assert!((lo.x - 10.0).abs() < 1e-9 && (lo.y - 20.0).abs() < 1e-9 && (hi.x - 18.0).abs() < 1e-9 && (hi.y - 27.0).abs() < 1e-9, "{lo:?} {hi:?}");
let inner = o.iter().filter(|g| matches!(g, LinkGeom::Line(a, _) if a.x > 10.5 && a.x < 17.5 && a.y > 20.5 && a.y < 26.5)).count();
assert_eq!(inner, 4, "{o:?}");
// The next glyph starts one advance later: an I after the 700 units of an H is 7 mm to the right.
let hi_text = text_geometry_in(Some(&folder.font()), "HI", Vec2::ZERO, 7.0, 0.0).unwrap();
let (lo, hi) = extent(&hi_text[4..]);
assert!((lo.x - 7.0).abs() < 1e-9 && (hi.x - 8.0).abs() < 1e-9 && (hi.y - 7.0).abs() < 1e-9, "{lo:?} {hi:?}");
// Without a font name the built-in one is used: its O is not a block.
let builtin = text_geometry_in(None, "O", v(10.0, 20.0), 7.0, 0.0).unwrap();
assert!(builtin.iter().all(|g| !matches!(g, LinkGeom::Line(..))));
assert_eq!(builtin, text_geometry("O", v(10.0, 20.0), 7.0, 0.0).unwrap());
// Turned by 90 degrees about its start, the 8 x 7 mm block stands 7 mm wide and 8 mm tall, left of the start.
let turned = text_geometry_in(Some(&folder.font()), "O", Vec2::ZERO, 7.0, std::f64::consts::FRAC_PI_2).unwrap();
let (lo, hi) = extent(&turned);
assert!((lo.x + 7.0).abs() < 1e-9 && hi.x.abs() < 1e-9 && lo.y.abs() < 1e-9 && (hi.y - 8.0).abs() < 1e-9, "{lo:?} {hi:?}");
}
#[test]
fn a_font_without_a_capital_height_or_an_h_uses_seven_tenths_of_the_em() {
let font = build_font("Bar", &[Glyph { ch: 'I', advance: 200, contours: vec![block(0, 0, 100, 700)] }]);
let folder = Folder::new("no-h");
let path = folder.0.join("bar.ttf");
std::fs::write(&path, font).unwrap();
// 0.7 em = 700 units: the 100 x 700 bar at a capital height of 14 mm is 2 x 14 mm.
let bar = text_geometry_in(Some(&path.to_string_lossy()), "I", Vec2::ZERO, 14.0, 0.0).unwrap();
let (lo, hi) = extent(&bar);
assert!(lo.x.abs() < 1e-9 && lo.y.abs() < 1e-9 && (hi.x - 2.0).abs() < 1e-9 && (hi.y - 14.0).abs() < 1e-9, "{lo:?} {hi:?}");
}
#[test]
fn text_in_a_font_closes_into_profiles_with_counters() {
let folder = Folder::new("profiles");
let (at, height) = (v(10.0, 20.0), 7.0);
let font = folder.font();
let source = LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()) };
let mut sk = Sketch::new();
sk.add_link(LinkKind::Text, source, &text_geometry_in(Some(&font), "O", at, height, 0.0).unwrap()).unwrap();
// The block with the counter in it is 8 x 7 mm minus 4 x 3 mm; the counter is a region of its own.
let mut areas: Vec<f64> = find_profiles(&sk).iter().map(|p| p.area).collect();
areas.sort_by(f64::total_cmp);
assert_eq!(areas.len(), 2, "{areas:?}");
assert!((areas[0] - 12.0).abs() < 1e-9 && (areas[1] - 44.0).abs() < 1e-9, "{areas:?}");
}
#[test]
fn fonts_resolve_by_file_by_installed_name_or_to_the_built_in_one() {
let folder = Folder::new("names");
// No name, an empty one or the built-in family in any spelling: the built-in font.
for name in [None, Some(""), Some(" "), Some("Liberation Sans"), Some("liberation-sans")] {
assert_eq!(font_data(name).unwrap().bytes(), font_data(None).unwrap().bytes(), "{name:?}");
}
// A file by its path; its bytes are those of the file.
let by_file = font_data(Some(&folder.font())).unwrap();
assert_eq!(by_file.bytes(), tiny_font().as_slice());
// A family by name in a font folder (searched below it, other files skipped), in any spelling; also by its full name.
for name in ["Tiny Block", "tiny_block", "TINYBLOCK", "tiny-block", "Tiny Block Regular"] {
let found = find_family_in(std::slice::from_ref(&folder.0), name).unwrap().unwrap_or_else(|| panic!("{name}"));
assert_eq!(found.bytes(), tiny_font().as_slice(), "{name}");
}
assert!(find_family_in(std::slice::from_ref(&folder.0), "No Such Family").unwrap().is_none());
assert!(find_family_in(&[folder.0.join("missing")], "Tiny Block").unwrap().is_none());
// The second font of the repository, by its family name.
let bundled = find_family_in(&[web_fonts()], "BIZ UDPGothic").unwrap().unwrap();
assert_eq!(bundled.bytes(), std::fs::read(web_fonts().join("BIZUDPGothic-Regular.ttf")).unwrap().as_slice());
}
#[test]
fn unknown_fonts_and_unreadable_files_are_errors() {
let folder = Folder::new("errors");
let message = |name: &str| font_data(Some(name)).err().unwrap().to_string();
assert!(message("No Such Family 12345").contains("unknown font `No Such Family 12345`"));
assert!(message("C:/no/such/dir/font.ttf").contains("font file `C:/no/such/dir/font.ttf`"));
assert!(message(&folder.0.join("notes.txt").to_string_lossy()).contains("font file"));
assert!(message(&folder.0.join("broken.ttf").to_string_lossy()).contains("is not a TrueType/OpenType font"));
assert!(message(&"x".repeat(2000)).contains("too long"));
// The error reaches text_geometry_in, which makes no geometry from it.
assert!(text_geometry_in(Some("No Such Family 12345"), "A", Vec2::ZERO, 8.0, 0.0).is_err());
}
#[test]
fn a_font_name_is_a_path_when_it_has_a_separator_or_a_font_extension() {
let message = |name: &str| font_data(Some(name)).err().unwrap().to_string();
// A name with a separator or a font extension is a file, whatever else it looks like; `%VAR%` is expanded in it and a
// variable that is not set (or no variable at all) stays as written.
assert!(message("%NO_SUCH_VAR_SOLIDCRAFT%/a.ttf").contains("font file `%NO_SUCH_VAR_SOLIDCRAFT%/a.ttf`"));
assert!(message("100% sure.ttf").contains("font file `100% sure.ttf`"));
assert!(message("Arial.TTF").contains("font file `Arial.TTF`"));
assert!(message("fonts/a.otf").contains("font file `fonts/a.otf`"));
// Anything else is a family name.
assert!(message("No Such Family 12345").contains("unknown font"));
}
#[test]
fn a_text_that_the_font_makes_too_large_is_refused() {
// A glyph of 300 corner points, 20 000 lines at most in a link: 66 of them are 19 800 lines, 67 are 20 100.
let ring: Vec<(i16, i16)> =
(0..300).map(|i| ((500.0 * (f64::from(i) * 0.0209).cos()) as i16, (500.0 * (f64::from(i) * 0.0209).sin()) as i16)).collect();
let font = build_font("Many", &[Glyph { ch: 'H', advance: 100, contours: vec![ring] }]);
let folder = Folder::new("large");
let path = folder.0.join("many.ttf").to_string_lossy().into_owned();
std::fs::write(&path, font).unwrap();
assert_eq!(text_geometry_in(Some(&path), &"H".repeat(66), Vec2::ZERO, 5.0, 0.0).unwrap().len(), 66 * 300);
assert!(matches!(text_geometry_in(Some(&path), &"H".repeat(67), Vec2::ZERO, 5.0, 0.0), Err(SketchError::TooLarge)));
}
#[test]
fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() {
let source = |font: Option<&str>| LinkSource::Text { text: "I".into(), at: Vec2::ZERO, height: 5.0, angle: 0.0, font: font.map(str::to_string) };
// No font: nothing is written, so the text of a design reads and writes as it did before fonts, and a design saved
// before them reads as text in the built-in font.
let plain = serde_json::to_string(&source(None)).unwrap();
assert!(!plain.contains("font"), "{plain}");
assert_eq!(serde_json::from_str::<LinkSource>(&plain).unwrap(), source(None));
// A font name or path comes back as it was written.
let json = serde_json::to_string(&source(Some("C:/fonts/Some Font.ttf"))).unwrap();
assert!(json.contains(r#""font":"C:/fonts/Some Font.ttf""#), "{json}");
assert_eq!(serde_json::from_str::<LinkSource>(&json).unwrap(), source(Some("C:/fonts/Some Font.ttf")));
}
+313
View File
@@ -0,0 +1,313 @@
//! Fonts for sketch text: the embedded default (Liberation Sans), a TrueType/OpenType file
//! named by its path, or an installed font named by its family or full name, looked up in the
//! system's and the user's font folders (plus `SOLIDCRAFT_FONT_DIRS`).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use crate::model::{Result, SketchError};
static DEFAULT_FONT: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
/// Family name of the built-in font (also what an empty or absent font name means).
pub const DEFAULT_FONT_FAMILY: &str = "Liberation Sans";
/// Largest font file read.
const MAX_FONT_BYTES: u64 = 64 << 20;
/// Most font files looked at when searching the font folders.
const MAX_FONT_FILES: usize = 20_000;
/// Deepest folder level searched below a font folder.
const MAX_DIR_DEPTH: usize = 6;
/// Font files kept in memory once read, and the most bytes they may add up to.
const MAX_CACHED_FONTS: usize = 16;
const MAX_CACHED_BYTES: usize = 128 << 20;
/// Longest font name or path accepted.
const MAX_FONT_NAME: usize = 1024;
/// The bytes of a font file and the face to use in it (collections hold several faces).
#[derive(Clone)]
pub struct FontData {
bytes: FontBytes,
pub index: u32,
}
#[derive(Clone)]
enum FontBytes {
Builtin,
File(Arc<Vec<u8>>),
}
impl FontData {
pub fn bytes(&self) -> &[u8] {
match &self.bytes {
FontBytes::Builtin => DEFAULT_FONT,
FontBytes::File(b) => b.as_slice(),
}
}
}
/// The font a text names: `None`, an empty name or the built-in family give the built-in
/// font; a name with a path separator or a font file extension is a file (`~` and `%VAR%` are
/// expanded); anything else is the family (or full) name of an installed font.
pub fn font_data(name: Option<&str>) -> Result<FontData> {
let name = name.map(str::trim).unwrap_or("");
if name.is_empty() || normalize(name) == normalize(DEFAULT_FONT_FAMILY) {
return Ok(FontData { bytes: FontBytes::Builtin, index: 0 });
}
if name.len() > MAX_FONT_NAME {
return Err(SketchError::Invalid("the font name is too long".into()));
}
if is_path(name) {
let path = expand(name);
let bytes = read_font(&path).map_err(|e| SketchError::Invalid(format!("font file `{name}`: {e}")))?;
let index = 0;
ttf_parser::Face::parse(&bytes, index)
.map_err(|e| SketchError::Invalid(format!("font file `{name}` is not a TrueType/OpenType font: {e}")))?;
return Ok(FontData { bytes: FontBytes::File(bytes), index });
}
find_family_in(&font_dirs(), name)?
.ok_or_else(|| SketchError::Invalid(format!("unknown font `{name}`: not an installed font family, and not a .ttf/.otf file path")))
}
/// An installed face whose family, full or PostScript name is `name`, searched in `dirs`.
pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result<Option<FontData>> {
let want = normalize(name);
if want.is_empty() {
return Ok(None);
}
let files = font_files(dirs);
// Files whose name resembles the family come first (arial.ttf for Arial, times.ttf for
// Times New Roman): most lookups end there without opening every installed font.
let (likely, rest): (Vec<&PathBuf>, Vec<&PathBuf>) = files.iter().partition(|p| {
let stem = p.file_stem().map(|s| normalize(&s.to_string_lossy())).unwrap_or_default();
stem.len() >= 3 && (stem.contains(&want) || want.contains(&stem))
});
for group in [likely, rest] {
let mut best: Option<(u32, PathBuf, u32)> = None;
for path in group {
for face in face_names(path).iter() {
let score = face.score(&want);
if score > 0 && best.as_ref().is_none_or(|(b, _, _)| score > *b) {
best = Some((score, path.clone(), face.index));
}
}
if best.as_ref().is_some_and(|(s, _, _)| *s >= 4) {
break;
}
}
if let Some((_, path, index)) = best {
let bytes = read_font(&path).map_err(|e| SketchError::Invalid(format!("font file `{}`: {e}", path.display())))?;
return Ok(Some(FontData { bytes: FontBytes::File(bytes), index }));
}
}
Ok(None)
}
/// The names of one face of a font file (normalised), for matching.
struct FaceNames {
index: u32,
regular: bool,
names: Vec<(u16, String)>,
}
impl FaceNames {
/// How well the face answers to a (normalised) name: 4 full or PostScript name, 3 family
/// name of a regular face, 2 family name of another style, 0 no match.
fn score(&self, want: &str) -> u32 {
use ttf_parser::name_id;
self.names
.iter()
.filter(|(_, n)| n == want)
.map(|(id, _)| match *id {
name_id::FULL_NAME | name_id::POST_SCRIPT_NAME => 4,
name_id::FAMILY | name_id::TYPOGRAPHIC_FAMILY if self.regular => 3,
name_id::FAMILY | name_id::TYPOGRAPHIC_FAMILY => 2,
_ => 0,
})
.max()
.unwrap_or(0)
}
}
/// The faces' names of a font file, read once per process (a lookup that finds nothing reads
/// every installed font once, not on every try).
fn face_names(path: &Path) -> Arc<Vec<FaceNames>> {
static NAMES: OnceLock<Mutex<HashMap<PathBuf, Arc<Vec<FaceNames>>>>> = OnceLock::new();
let cache = NAMES.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(c) = cache.lock()
&& let Some(n) = c.get(path)
{
return n.clone();
}
let mut faces = Vec::new();
if let Ok(bytes) = read_file(path) {
let count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1).min(64);
for index in 0..count {
let Ok(face) = ttf_parser::Face::parse(&bytes, index) else { continue };
let names = face
.names()
.into_iter()
.filter_map(|n| {
let text = match n.to_string() {
Some(t) => t,
// Macintosh Roman names: their ASCII part.
None if n.platform_id == ttf_parser::PlatformId::Macintosh => {
n.name.iter().filter(|b| b.is_ascii()).map(|b| *b as char).collect()
}
None => return None,
};
Some((n.name_id, normalize(&text)))
})
.collect();
faces.push(FaceNames { index, regular: face.is_regular() || (!face.is_bold() && !face.is_italic()), names });
}
}
let faces = Arc::new(faces);
if let Ok(mut c) = cache.lock()
&& c.len() < MAX_FONT_FILES
{
c.insert(path.to_path_buf(), faces.clone());
}
faces
}
/// Lower-case letters and digits only: "DejaVu_Sans", "Dejavu Sans" and "dejavu-sans" are one name.
fn normalize(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
fn is_path(name: &str) -> bool {
name.contains('/') || name.contains('\\') || has_font_extension(name)
}
fn has_font_extension(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
[".ttf", ".otf", ".ttc", ".otc"].iter().any(|e| lower.ends_with(e))
}
/// `~` at the start and `%VAR%` replaced from the environment.
fn expand(name: &str) -> PathBuf {
let mut s = name.to_string();
if let Some(rest) = s.strip_prefix('~')
&& (rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\'))
&& let Some(home) = home()
{
s = format!("{}{rest}", home.display());
}
let mut out = String::new();
let mut rest = s.as_str();
while let Some((head, tail)) = rest.split_once('%') {
out.push_str(head);
match tail.split_once('%') {
// A variable that is not set stays as written.
Some((var, after)) if !var.is_empty() && !var.contains(['=', '\0']) => {
match std::env::var(var) {
Ok(v) => out.push_str(&v),
Err(_) => {
out.push('%');
out.push_str(var);
out.push('%');
}
}
rest = after;
}
_ => {
out.push('%');
rest = tail;
}
}
}
out.push_str(rest);
PathBuf::from(out)
}
fn home() -> Option<PathBuf> {
std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)
}
/// The folders searched for installed fonts, in order.
pub fn font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::env::var_os("SOLIDCRAFT_FONT_DIRS").map(|v| std::env::split_paths(&v).collect()).unwrap_or_default();
let env = |k: &str| std::env::var_os(k).map(PathBuf::from);
if cfg!(windows) {
dirs.push(env("WINDIR").or_else(|| env("SystemRoot")).unwrap_or_else(|| PathBuf::from("C:\\Windows")).join("Fonts"));
if let Some(local) = env("LOCALAPPDATA") {
dirs.push(local.join("Microsoft").join("Windows").join("Fonts"));
}
} else if cfg!(target_os = "macos") {
dirs.push(PathBuf::from("/System/Library/Fonts"));
dirs.push(PathBuf::from("/Library/Fonts"));
if let Some(h) = home() {
dirs.push(h.join("Library").join("Fonts"));
}
} else {
if let Some(d) = env("XDG_DATA_HOME") {
dirs.push(d.join("fonts"));
}
if let Some(h) = home() {
dirs.push(h.join(".local").join("share").join("fonts"));
dirs.push(h.join(".fonts"));
}
dirs.push(PathBuf::from("/usr/local/share/fonts"));
dirs.push(PathBuf::from("/usr/share/fonts"));
}
dirs
}
/// TrueType/OpenType files under `dirs` (bounded in depth and count).
fn font_files(dirs: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut todo: Vec<(PathBuf, usize)> = dirs.iter().rev().map(|d| (d.clone(), 0)).collect();
while let Some((dir, depth)) = todo.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
let mut sub = Vec::new();
for e in entries.flatten() {
let path = e.path();
let Ok(kind) = e.file_type() else { continue };
if kind.is_dir() {
if depth < MAX_DIR_DEPTH {
sub.push((path, depth + 1));
}
} else if has_font_extension(&path.to_string_lossy()) {
out.push(path);
if out.len() >= MAX_FONT_FILES {
return out;
}
}
}
sub.sort();
todo.extend(sub.into_iter().rev());
}
out
}
/// A font file's bytes (bounded in size).
fn read_file(path: &Path) -> std::result::Result<Vec<u8>, String> {
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !meta.is_file() {
return Err("not a file".into());
}
if meta.len() > MAX_FONT_BYTES {
return Err("the file is too large for a font".into());
}
std::fs::read(path).map_err(|e| e.to_string())
}
/// A font file's bytes, from the cache when it was read before.
fn read_font(path: &Path) -> std::result::Result<Arc<Vec<u8>>, String> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<Vec<u8>>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(c) = cache.lock()
&& let Some(b) = c.get(path)
{
return Ok(b.clone());
}
let bytes = Arc::new(read_file(path)?);
if let Ok(mut c) = cache.lock() {
if c.len() >= MAX_CACHED_FONTS || c.values().map(|b| b.len()).sum::<usize>() + bytes.len() > MAX_CACHED_BYTES {
c.clear();
}
c.insert(path.to_path_buf(), bytes.clone());
}
Ok(bytes)
}
+5 -1
View File
@@ -20,6 +20,7 @@ mod construct;
mod curves;
mod decompose;
mod dims;
mod fonts;
mod freeform_offset;
mod linalg;
mod link;
@@ -41,6 +42,7 @@ pub use curves::{
};
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, font_data};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};
@@ -55,7 +57,7 @@ pub use shape::{Shape, intersections};
pub use solver::{
Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, text_geometry};
pub use text::{MAX_TEXT_CHARS, text_geometry, text_geometry_in};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)]
@@ -65,6 +67,8 @@ mod construct_tests;
#[cfg(test)]
mod continuation_tests;
#[cfg(test)]
mod font_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
+4 -1
View File
@@ -71,13 +71,16 @@ pub enum LinkSource {
Iso { body: String, at: Vec3, dir: String },
/// The outline of a body spun about an axis (world line), laid into the sketch plane.
Spun { body: String, origin: Vec3, dir: Vec3 },
/// Text: baseline start `at` (sketch coordinates), capital height and angle (radians).
/// Text: baseline start `at` (sketch coordinates), capital height and angle (radians), in
/// `font` (a font file path or an installed family name; none: the built-in font).
Text {
text: String,
at: Vec2,
height: f64,
#[serde(default)]
angle: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
font: Option<String>,
},
}
+1 -1
View File
@@ -343,7 +343,7 @@ fn merging_adjacent_regions() {
/// outlines.
fn text_sketch(text: &str, at: Vec2, height: f64) -> Sketch {
let mut sk = Sketch::new();
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0 };
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None };
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
sk
}
+73 -18
View File
@@ -1,14 +1,17 @@
//! Sketch text: glyph outlines of an embedded open font (Liberation Sans, SIL OFL 1.1; see
//! `fonts/OFL.txt` and ATTRIBUTION.md) as lines and conics (TrueType quadratic Béziers are
//! conics with rho = 0.5), so text closes into profiles that can be extruded.
//! Sketch text: glyph outlines of a font as lines, conics and cubic splines, so text closes
//! into profiles that can be extruded. TrueType quadratic Béziers are conics with rho = 0.5;
//! OpenType (CFF) cubic Béziers are cubic control splines, both exact. A glyph's counters (the
//! holes of A, B, 8…) are loops inside its outline; profile detection makes them holes.
//!
//! The default font is embedded (Liberation Sans, SIL OFL 1.1; see `fonts/OFL.txt` and
//! ATTRIBUTION.md); [`crate::fonts`] finds others by file path or installed family name.
use solidcraft_geom::Vec2;
use crate::link::LinkGeom;
use crate::fonts::font_data;
use crate::link::{LinkGeom, MAX_LINK_ENTITIES};
use crate::model::{Result, SketchError};
static FONT: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
/// Most characters in one text.
pub const MAX_TEXT_CHARS: usize = 1000;
@@ -37,6 +40,20 @@ impl Outline {
self.out.push(LinkGeom::Conic { a, apex, b, rho: 0.5 });
self.cur = to;
}
fn cubic(&mut self, c1: Vec2, c2: Vec2, to: Vec2) {
let (a, b) = (self.cur, to);
let chord = b - a;
let l = chord.len().max(1e-12);
let off = (chord.cross(c1 - a).abs() / l).max(chord.cross(c2 - a).abs() / l);
// Straight (controls on the chord, between its ends): a line.
let between = |c: Vec2| (c - a).dot(chord) >= -1e-9 && (c - b).dot(chord) <= 1e-9;
if a.dist(b) < 1e-9 || (off < 1e-6 && between(c1) && between(c2)) {
self.line(to);
return;
}
self.out.push(LinkGeom::Spline { pts: vec![a, c1, c2, b], control: true, degree: 3, periodic: false });
self.cur = to;
}
}
impl ttf_parser::OutlineBuilder for Outline {
@@ -54,13 +71,8 @@ impl ttf_parser::OutlineBuilder for Outline {
self.quad(c, p);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
// A cubic as two quadratics (split at the middle).
let (p0, c1, c2, p3) = (self.cur, (self.map)(x1, y1), (self.map)(x2, y2), (self.map)(x, y));
let mid = (p0 + c1 * 3.0 + c2 * 3.0 + p3) / 8.0;
let q1 = p0 + (c1 - p0) * 0.75;
let q2 = p3 + (c2 - p3) * 0.75;
self.quad(q1, mid);
self.quad(q2, p3);
let (c1, c2, p) = ((self.map)(x1, y1), (self.map)(x2, y2), (self.map)(x, y));
self.cubic(c1, c2, p);
}
fn close(&mut self) {
let s = self.start;
@@ -68,19 +80,27 @@ impl ttf_parser::OutlineBuilder for Outline {
}
}
/// Outline geometry of `text` with its baseline starting at `at`, capital height `height`
/// (mm), rotated by `angle` (radians) about `at`. Lines break at `\n`.
/// Outline geometry of `text` in the built-in font (see [`text_geometry_in`]).
pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Vec<LinkGeom>> {
text_geometry_in(None, text, at, height, angle)
}
/// Outline geometry of `text` in `font` (None: the built-in font; else a font file path or an
/// installed family name, see [`crate::fonts::font_data`]) with its baseline starting at `at`,
/// capital height `height` (mm), rotated by `angle` (radians) about `at`. Lines break at `\n`.
pub fn text_geometry_in(font: Option<&str>, text: &str, at: Vec2, height: f64, angle: f64) -> Result<Vec<LinkGeom>> {
if text.chars().count() > MAX_TEXT_CHARS {
return Err(SketchError::TooLarge);
}
if !(height.is_finite() && height > 1e-6 && height < 1e6 && angle.is_finite() && at.is_finite()) {
return Err(SketchError::Invalid("text height must be positive".into()));
}
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
let cap = face.capital_height().map(f64::from).filter(|c| *c > 0.0).unwrap_or(f64::from(face.units_per_em()) * 0.7);
let data = font_data(font)?;
let face = ttf_parser::Face::parse(data.bytes(), data.index).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
let cap = cap_height(&face);
let k = height / cap;
let line_gap = f64::from(face.ascender() - face.descender() + face.line_gap()) * k;
let line_gap = f64::from(face.ascender()) - f64::from(face.descender()) + f64::from(face.line_gap());
let line_gap = if line_gap > 0.0 { line_gap * k } else { height * 1.5 };
let (s, c) = angle.sin_cos();
let mut out = Vec::new();
for (row, line) in text.split('\n').enumerate() {
@@ -96,12 +116,26 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
let mut ob = Outline { out: Vec::new(), start: at, cur: at, map };
let _ = face.outline_glyph(gid, &mut ob);
out.extend(ob.out);
// A font can give a glyph any number of points: stop where the link would be refused anyway.
if out.len() > MAX_LINK_ENTITIES {
return Err(SketchError::TooLarge);
}
pen += f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k;
}
}
Ok(out)
}
/// The font's capital height in font units: from its OS/2 table, else the top of its `H`, else
/// 70 % of the em.
fn cap_height(face: &ttf_parser::Face) -> f64 {
face.capital_height()
.map(f64::from)
.filter(|c| *c > 0.0)
.or_else(|| face.glyph_index('H').and_then(|g| face.glyph_bounding_box(g)).map(|b| f64::from(b.y_max)).filter(|c| *c > 0.0))
.unwrap_or(f64::from(face.units_per_em().max(1)) * 0.7)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -126,4 +160,25 @@ mod tests {
// Empty text makes nothing (not an error here; the command rejects it).
assert!(text_geometry("", Vec2::ZERO, 1.0, 0.0).unwrap().is_empty());
}
#[test]
fn a_font_file_gives_the_same_outlines_and_unknown_fonts_are_errors() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LiberationSans-Regular.ttf");
let by_file = text_geometry_in(Some(&path.to_string_lossy()), "A7_L", Vec2::ZERO, 8.0, 0.0).unwrap();
assert_eq!(by_file, text_geometry("A7_L", Vec2::ZERO, 8.0, 0.0).unwrap());
let e = text_geometry_in(Some("No Such Family 12345"), "A", Vec2::ZERO, 8.0, 0.0).unwrap_err().to_string();
assert!(e.contains("unknown font"), "{e}");
}
#[test]
fn cubic_outlines_stay_exact_splines() {
let mut ob = Outline { out: Vec::new(), start: Vec2::ZERO, cur: Vec2::ZERO, map: Box::new(|x, y| Vec2::new(f64::from(x), f64::from(y))) };
ttf_parser::OutlineBuilder::move_to(&mut ob, 0.0, 0.0);
ttf_parser::OutlineBuilder::curve_to(&mut ob, 0.0, 10.0, 10.0, 10.0, 10.0, 0.0);
// A cubic with its controls on the chord is a line.
ttf_parser::OutlineBuilder::curve_to(&mut ob, 7.0, 0.0, 3.0, 0.0, 0.0, 0.0);
assert_eq!(ob.out.len(), 2);
assert!(matches!(&ob.out[0], LinkGeom::Spline { pts, control: true, degree: 3, .. } if pts.len() == 4));
assert!(matches!(ob.out[1], LinkGeom::Line(..)));
}
}