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:
1 parent
7d00df0a3f
commit
a69c2928f0
15 files changed
+883
-41
No files matched your search
@@ -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")));
|
||||
}
|
||||
Reference in new issue
Block a user