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 <[email protected]>
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

+5
View File
@@ -55,6 +55,11 @@
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
//! notch they always had); the version stamp lets older readers request an update instead of
//! silently building notches where a design asks for another shape.
//! Under the same version, sketch text can be set in a font other than the built-in one
//! (`font` in the text link's source: an installed family or a font file); a text without
//! `font` is in the built-in font, as it always was.
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
//! move without it saves as before.
use serde_json::Value;
+1 -1
View File
@@ -262,7 +262,7 @@ fn resolve_uncached(
LinkSource::SketchAxis { name } => {
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
}
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
LinkSource::Text { text, at, height, angle, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
+19 -8
View File
@@ -80,16 +80,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
.params(concat!(
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
created_doc!()
)),
];
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
if text.trim().is_empty() {
return Err(bad(cmd, "`text` must not be empty"));
@@ -103,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
return Err(bad(cmd, "`height` must be positive"));
}
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
Ok(LinkSource::Text { text, at, height, angle })
let font = match p.get("font") {
None | Some(Value::Null) => f0,
Some(Value::String(f)) if f.trim().is_empty() => None,
Some(Value::String(f)) => Some(f.trim().to_string()),
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
};
// An unknown font or an unreadable file is an error now, not a lost link later.
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font })
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -133,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "give `link` or `entity`")),
};
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle })) => (text, at, height, angle),
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
let geom = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?;
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
+2 -1
View File
@@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Geom2::Text { text, at, height, angle } = t {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() {
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
{
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
n_text += 1;
@@ -495,6 +495,47 @@ fn text_embossed_or_debossed_leaves_its_counters_alone() {
}
}
#[test]
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
let near = |s: &Session, want: f64| {
let a = profiles(s);
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
};
let mut s = new_sketch();
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
near(&s, 13.5557);
let link = r["link"].as_str().unwrap().to_string();
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
near(&s, 17.6385);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
near(&s, 13.5557);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
near(&s, 4.0 * 17.6385);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
near(&s, 4.0 * 13.5557);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
// The font is kept in the design: after saving and reopening, the text is still in it.
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
// A new text takes the font too. Unknown fonts and values that are not names are errors.
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
near(&s, 17.6385);
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
near(&s, 17.6385);
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
+1 -1
View File
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
let text = sk
.add_link(
LinkKind::Text,
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
+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(..)));
}
}
+102 -8
View File
@@ -22,6 +22,9 @@ pub struct Tx {
pub(crate) link: String,
pub(crate) text: String,
initial_text: String,
/// The font as typed: an installed font's family name or a font file's path; empty is the built-in font.
pub(crate) font: String,
initial_font: String,
values: [String; 4],
/// The fields as first shown; an untouched field keeps its exact saved value.
initial: [String; 4],
@@ -57,15 +60,16 @@ fn shown(value: f64, unit: &str) -> String {
format!("{text} {unit}")
}
/// The saved text source behind `link` in the active sketch.
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<(String, [f64; 4])> {
/// The saved text source behind `link` in the active sketch: its text, its font (empty: the built-in
/// font), and x, y, height and angle.
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<(String, String, [f64; 4])> {
if app.session.active_sketch != Some(sketch) {
return None;
}
let sk = app.session.doc.sketch(sketch).ok()?;
let source = sk.link(link).filter(|source| source.kind == LinkKind::Text)?;
let LinkSource::Text { text, at, height, angle } = &source.source else { return None };
Some((text.clone(), [at.x, at.y, *height, *angle]))
let LinkSource::Text { text, at, height, angle, font } = &source.source else { return None };
Some((text.clone(), font.clone().unwrap_or_default(), [at.x, at.y, *height, *angle]))
}
fn fields(original: &[f64; 4], unit: &'static str, scale: f64) -> [String; 4] {
@@ -80,10 +84,22 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
let sketch = app.session.active_sketch?;
let sk = app.session.doc.sketch(sketch).ok()?;
let link = sk.curves.iter().find(|curve| curve.id == entity).and_then(|curve| curve.link.clone())?;
let (text, original) = source(app, sketch, &link)?;
let (text, font, original) = source(app, sketch, &link)?;
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let values = fields(&original, unit, scale);
Some(Kind::Text(Tx { sketch, link, initial_text: text.clone(), text, initial: values.clone(), values, original, unit, focus: true }))
Some(Kind::Text(Tx {
sketch,
link,
initial_text: text.clone(),
text,
initial_font: font.clone(),
font,
initial: values.clone(),
values,
original,
unit,
focus: true,
}))
}
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
@@ -114,7 +130,7 @@ pub(crate) fn editing(app: &SolveApp) -> bool {
/// text deleted or undone). Other design changes leave it open; when the saved source itself
/// changed (undo, a script), fields the user has not touched follow it.
pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
let Some((text, original)) = source(app, k.sketch, &k.link) else { return false };
let Some((text, font, original)) = source(app, k.sketch, &k.link) else { return false };
if original != k.original {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let fresh = fields(&original, unit, scale);
@@ -133,6 +149,12 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
}
k.initial_text = text;
}
if font != k.initial_font {
if k.font == k.initial_font {
k.font = font.clone();
}
k.initial_font = font;
}
true
}
@@ -147,6 +169,12 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
k.focus = false;
}
ui.end_row();
crate::dialogs::row_label(ui, "Font");
// Empty means the built-in font: its name is the hint.
let hint = solidcraft_engine::sketch::DEFAULT_FONT_FAMILY;
let response = ui.add(egui::TextEdit::singleline(&mut k.font).id_salt("sketch_text_font").hint_text(hint).desired_width(width));
crate::scenario::publish_handle("dialog:Font", response.rect.center());
ui.end_row();
let mut enter = false;
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
crate::dialogs::row_label(ui, label);
@@ -183,6 +211,10 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
if k.values.get(3) != k.initial.get(3) {
params["angle"] = json!(angle.to_degrees());
}
// An untouched font stays as saved; an emptied one is the built-in font.
if k.font.trim() != k.initial_font.trim() {
params["font"] = json!(k.font.trim());
}
Ok(vec![("sketch.edit_text".into(), params)])
}
@@ -247,6 +279,7 @@ mod tests {
at: solidcraft_engine::geom::Vec2::new(25.0, 12.0),
height: 20.0,
angle: std::f64::consts::FRAC_PI_2,
font: None,
};
assert_eq!(sk.link(&link).unwrap().source, expected);
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
@@ -272,7 +305,10 @@ mod tests {
replace(&mut h, "dialog:Text", "O");
ok(&mut h);
let LinkSource::Text { at, height, angle, .. } = original else { panic!("text source") };
assert_eq!(h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source, LinkSource::Text { text: "O".into(), at, height, angle });
assert_eq!(
h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source,
LinkSource::Text { text: "O".into(), at, height, angle, font: None }
);
h.app.session.selection = vec![Sel::SketchCurve { id: h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone() }];
h.app.start("sketch.edit_text");
h.frames(3);
@@ -291,6 +327,64 @@ mod tests {
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn the_font_row_sets_keeps_and_clears_the_font_of_a_text() {
// The repository's second font, BIZ UDPGothic: its "I" is 17.6385 mm² at a capital height of 10 mm, the built-in
// font's 13.5557 mm².
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
let (mut h, link, _) = setup();
// The dialog closes with the selection; the text's first outline is selected again to open it.
let reopen = |h: &mut Harness| {
let sid = h.app.session.active_sketch.unwrap();
let id = h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone();
h.app.session.selection = vec![Sel::SketchCurve { id }];
h.app.start("sketch.edit_text");
h.frames(3);
};
let area = |h: &Harness| -> f64 {
let sid = h.app.session.active_sketch.unwrap();
h.app.session.world_state().sketch(sid).unwrap().profiles.iter().map(|p| p.area).sum()
};
assert!((area(&h) - 13.5557).abs() < 2e-3);
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).font, "");
// A font that is not there keeps the dialog open with the reason, and the design as it was.
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
replace(&mut h, "dialog:Font", "No Such Family 12345");
ok(&mut h);
assert!(editing(&h.app));
assert!(h.app.dialog.as_ref().unwrap().error.as_ref().is_some_and(|e| e.contains("unknown font")));
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
// A font file: one undo step, other outlines, and the dialog shows the font when it is opened again.
replace(&mut h, "dialog:Font", font);
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(h.app.session.undo.len(), before.1 + 1);
let sid = h.app.session.active_sketch.unwrap();
let font_of = |h: &Harness| match &h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source {
LinkSource::Text { font, .. } => font.clone(),
other => panic!("{other:?}"),
};
assert_eq!(font_of(&h).as_deref(), Some(font));
assert!((area(&h) - 17.6385).abs() < 2e-3);
reopen(&mut h);
assert_eq!(tx(&h.app).font, font);
// Changing only the text keeps the font: two I's of it.
replace(&mut h, "dialog:Text", "II");
ok(&mut h);
assert_eq!(font_of(&h).as_deref(), Some(font));
assert!((area(&h) - 2.0 * 17.6385).abs() < 2e-3);
// An emptied field is the built-in font again: two I's of it.
reopen(&mut h);
h.step(&json!({"click":{"handle":"dialog:Font"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"key":"Backspace"})).unwrap();
ok(&mut h);
assert_eq!(font_of(&h), None);
assert!((area(&h) - 2.0 * 13.5557).abs() < 2e-3);
}
#[test]
fn invalid_text_values_and_cancel_leave_document_and_history_unchanged() {
let (mut h, _, _) = setup();