From a69c2928f08c1094d6b8e8ffede20a24deef8433 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:04:59 +0200 Subject: [PATCH] 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 --- crates/doc/src/format.rs | 5 + crates/doc/src/project.rs | 2 +- crates/engine/src/cmd/sketch_create.rs | 27 +- crates/engine/src/cmd/sketch_import.rs | 3 +- crates/engine/src/cmd/sketch_modify_tests.rs | 41 +++ crates/engine/src/view_display_tests.rs | 2 +- crates/sketch/src/font_tests.rs | 309 ++++++++++++++++++ crates/sketch/src/fonts.rs | 313 +++++++++++++++++++ crates/sketch/src/lib.rs | 6 +- crates/sketch/src/link.rs | 5 +- crates/sketch/src/tests.rs | 2 +- crates/sketch/src/text.rs | 91 ++++-- crates/ui-egui/src/sketch_text.rs | 110 ++++++- docs/book/src/guide/files.md | 2 +- docs/book/src/guide/sketching.md | 6 + 15 files changed, 883 insertions(+), 41 deletions(-) create mode 100644 crates/sketch/src/font_tests.rs create mode 100644 crates/sketch/src/fonts.rs diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 37644def..622d5076 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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; diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 1c57f105..b72b5bb2 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -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() } }; diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index fc486e34..4f94432d 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -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 { - 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)>) -> Result { + 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 { @@ -133,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { _ => 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)); diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 69fb8fa4..d1fcf98a 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result { 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; diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 1fef2b5d..c6de598a 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -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(); diff --git a/crates/engine/src/view_display_tests.rs b/crates/engine/src/view_display_tests.rs index f717a828..5a1808a5 100644 --- a/crates/engine/src/view_display_tests.rs +++ b/crates/engine/src/view_display_tests.rs @@ -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(); diff --git a/crates/sketch/src/font_tests.rs b/crates/sketch/src/font_tests.rs new file mode 100644 index 00000000..5991a9e3 --- /dev/null +++ b/crates/sketch/src/font_tests.rs @@ -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>, +} + +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 { + fn u16s(out: &mut Vec, values: &[u16]) { + values.iter().for_each(|v| out.extend(v.to_be_bytes())); + } + fn u32s(out: &mut Vec, 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, Vec) = 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 = 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); 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 { + 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 = 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 = 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::(&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::(&json).unwrap(), source(Some("C:/fonts/Some Font.ttf"))); +} diff --git a/crates/sketch/src/fonts.rs b/crates/sketch/src/fonts.rs new file mode 100644 index 00000000..1214a541 --- /dev/null +++ b/crates/sketch/src/fonts.rs @@ -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>), +} + +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 { + 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> { + 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> { + static NAMES: OnceLock>>>> = 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 { + 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 { + let mut dirs: Vec = 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 { + 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, 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>, String> { + static CACHE: OnceLock>>>> = 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::() + bytes.len() > MAX_CACHED_BYTES { + c.clear(); + } + c.insert(path.to_path_buf(), bytes.clone()); + } + Ok(bytes) +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 1e5fba89..a445f45f 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -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)] diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 846e9c04..65826610 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -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, }, } diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 5fa4f885..2a5c0a6c 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -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 } diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs index a45898d6..bf694aba 100644 --- a/crates/sketch/src/text.rs +++ b/crates/sketch/src/text.rs @@ -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> { + 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> { 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 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(..))); + } } diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index b99c9da4..ec3b6b67 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -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 { 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, 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(); diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 2db1f5bf..a7ce8284 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -51,7 +51,7 @@ keeps the current design. Previews cannot be edited or measured. This experiment requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches. The repository's `docs/design-container.md` describes limits and encoding details. -Current designs use format 16, including sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis +Current designs use format 16, including the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis links, persistent names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 28e7b438..9ec97dd8 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -31,6 +31,12 @@ fit-point and control-point splines, conics, points and text. **Construction** ( into reference geometry that doesn't form profiles. Modify tools include trim (T), extend, break, fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. +**Text** is set in the built-in font (Liberation Sans) unless it is given another: `sketch.text` and +`sketch.edit_text` take a `font`, the family or full name of an installed font ("DejaVu Sans") or the path +of a `.ttf` / `.otf` file, and the **Edit Text** dialog has a **Font** field for it. An unknown font is an +error, and the font is kept with the text in the design, so the text stays editable in it. `SOLIDCRAFT_FONT_DIRS` +adds folders to the ones searched for installed fonts. + **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. For Project, select visible source curves or points from another sketch, model edges,