//! 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 `assets/fonts/liberation-sans/OFL.txt` and //! ATTRIBUTION.md); [`crate::fonts`] finds others by file path or installed family name. use serde::{Deserialize, Serialize}; use solidcraft_geom::Vec2; use crate::fonts::font_data_styled; use crate::link::{LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; use crate::model::{Result, Sketch, SketchError}; /// Most characters in one text. pub const MAX_TEXT_CHARS: usize = 1000; struct Outline { out: Vec, start: Vec2, cur: Vec2, map: Box Vec2>, } impl Outline { fn line(&mut self, to: Vec2) { if self.cur.dist(to) > 1e-9 { self.out.push(LinkGeom::Line(self.cur, to)); } self.cur = to; } fn quad(&mut self, apex: Vec2, to: Vec2) { let (a, b) = (self.cur, to); let chord = b - a; let off = chord.cross(apex - a).abs() / chord.len().max(1e-12); if off < 1e-6 || a.dist(b) < 1e-9 { self.line(to); return; } 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 { fn move_to(&mut self, x: f32, y: f32) { let p = (self.map)(x, y); self.start = p; self.cur = p; } fn line_to(&mut self, x: f32, y: f32) { let p = (self.map)(x, y); self.line(p); } fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { let (c, p) = ((self.map)(x1, y1), (self.map)(x, y)); self.quad(c, p); } fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { 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; self.line(s); } } /// Where each line of a text sits against its anchor (`at`, or a point of its path). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TextAlign { /// The line starts at the anchor. #[default] Left, /// The line is centred on it. Center, /// The line ends at it. Right, } impl TextAlign { fn is_left(&self) -> bool { *self == TextAlign::Left } } /// How a text is set: the style of its font, where its lines sit, the space between its letters /// and lines, and the sketch curve its baseline follows (instead of the straight line from `at`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextStyle { #[serde(default, skip_serializing_if = "is_false")] pub bold: bool, #[serde(default, skip_serializing_if = "is_false")] pub italic: bool, #[serde(default, skip_serializing_if = "TextAlign::is_left")] pub align: TextAlign, /// Extra space between letters (mm; negative brings them closer). #[serde(default, skip_serializing_if = "is_zero")] pub letter_spacing: f64, /// The distance between lines as a multiple of the font's own. #[serde(default = "one", skip_serializing_if = "is_one")] pub line_spacing: f64, /// The id of a curve of the same sketch the baseline follows (`at` and the angle then do not /// place the text). #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, /// Along the path the other way round, so the text sits on its other side. #[serde(default, skip_serializing_if = "is_false")] pub reverse: bool, } impl Default for TextStyle { fn default() -> Self { TextStyle { bold: false, italic: false, align: TextAlign::Left, letter_spacing: 0.0, line_spacing: 1.0, path: None, reverse: false } } } impl TextStyle { /// Nothing but the defaults (what a text saved before styles had). pub fn is_plain(&self) -> bool { *self == TextStyle::default() } } fn is_false(b: &bool) -> bool { !*b } fn is_zero(x: &f64) -> bool { *x == 0.0 } fn one() -> f64 { 1.0 } fn is_one(x: &f64) -> bool { *x == 1.0 } /// How far a slanted (made-up italic) glyph leans: 12 degrees. const SLANT_DEG: f64 = 12.0; /// Largest letter spacing (mm either way) and line spacing factor accepted. const MAX_LETTER_SPACING: f64 = 1e5; const LINE_SPACING_RANGE: (f64, f64) = (0.05, 100.0); /// 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> { text_geometry_styled(font, text, at, height, angle, &TextStyle::default(), None) } /// Outline geometry of `text` set in `style` (see [`text_geometry_in`]). With `path` (the /// points of the curve [`TextStyle::path`] names) the baseline follows it: each letter stands on /// the curve, turned with it, and the lines after the first run below it. pub fn text_geometry_styled( font: Option<&str>, text: &str, at: Vec2, height: f64, angle: f64, style: &TextStyle, path: Option<&[Vec2]>, ) -> 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())); } if !(style.letter_spacing.is_finite() && style.letter_spacing.abs() <= MAX_LETTER_SPACING) { return Err(SketchError::Invalid("the letter spacing must be a finite length".into())); } if !(style.line_spacing.is_finite() && (LINE_SPACING_RANGE.0..=LINE_SPACING_RANGE.1).contains(&style.line_spacing)) { return Err(SketchError::Invalid(format!( "the line spacing must be between {} and {} times the font's", LINE_SPACING_RANGE.0, LINE_SPACING_RANGE.1 ))); } let (data, slant) = font_data_styled(font, style.bold, style.italic)?; let face = ttf_parser::Face::parse(data.bytes(), data.index).map_err(|e| SketchError::Invalid(format!("font: {e}")))?; let k = height / cap_height(&face); 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 } * style.line_spacing; let shear = if slant { SLANT_DEG.to_radians().tan() } else { 0.0 }; // Each line's glyphs (glyph, where its origin is along the line, its advance) and width. let lines: Vec<(Vec<(ttf_parser::GlyphId, f64, f64)>, f64)> = text .split('\n') .map(|line| { let mut pen = 0.0f64; let mut glyphs = Vec::new(); for ch in line.chars() { let Some(gid) = face.glyph_index(ch).or_else(|| face.glyph_index('?')) else { continue }; let advance = f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k; glyphs.push((gid, pen, advance)); pen += advance + style.letter_spacing; } let width = glyphs.last().map(|(_, x, advance)| x + advance).unwrap_or(0.0); (glyphs, width) }) .collect(); let along = path.map(|pts| Path::new(pts, style.reverse)).transpose()?; let anchor = along.as_ref().map(|p| p.anchor(style.align)).unwrap_or(0.0); let (sin, cos) = angle.sin_cos(); let mut out = Vec::new(); for (row, (glyphs, width)) in lines.iter().enumerate() { let base_y = -(row as f64) * line_gap; let start = match style.align { TextAlign::Left => 0.0, TextAlign::Center => -width / 2.0, TextAlign::Right => -width, }; for &(gid, x, advance) in glyphs { // The glyph's frame: its origin on the baseline and the way the baseline runs. let (origin, dir) = match &along { None => { let dir = Vec2::new(cos, sin); (at + dir * (start + x) + Vec2::new(-sin, cos) * base_y, dir) } Some(p) => { let (mid, dir) = p.frame(anchor + start + x + advance / 2.0, advance); (mid - dir * (advance / 2.0) + Vec2::new(-dir.y, dir.x) * base_y, dir) } }; let up = Vec2::new(-dir.y, dir.x); let map = Box::new(move |gx: f32, gy: f32| { let (gx, gy) = (f64::from(gx) * k, f64::from(gy) * k); origin + dir * (gx + gy * shear) + up * gy }); let mut ob = Outline { out: Vec::new(), start: origin, cur: origin, 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); } } } if out.iter().any(|g| !geom_finite(g)) { return Err(SketchError::Invalid("the text does not fit its path".into())); } Ok(out) } fn geom_finite(g: &LinkGeom) -> bool { match g { LinkGeom::Line(a, b) => a.is_finite() && b.is_finite(), LinkGeom::Conic { a, apex, b, rho } => a.is_finite() && apex.is_finite() && b.is_finite() && rho.is_finite(), LinkGeom::Spline { pts, .. } => pts.iter().all(|p| p.is_finite()), _ => true, } } /// A curve a text follows, as points with the distance along it to each: oriented so the text /// reads left to right (an open curve runs rightwards, a closed one clockwise, the text on its /// outside) unless reversed. struct Path { pts: Vec, along: Vec, closed: bool, } impl Path { fn new(points: &[Vec2], reverse: bool) -> Result { let mut pts: Vec = Vec::with_capacity(points.len()); for p in points { if !p.is_finite() { return Err(SketchError::Invalid("the text's path is not a finite curve".into())); } if pts.last().is_none_or(|q: &Vec2| q.dist(*p) > 1e-9) { pts.push(*p); } } let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { return Err(SketchError::Invalid("the text's path has no length".into())); }; let length: f64 = pts.iter().zip(pts.iter().skip(1)).map(|(a, b)| a.dist(*b)).sum(); if pts.len() < 2 || length < 1e-9 { return Err(SketchError::Invalid("the text's path has no length".into())); } let closed = first.dist(last) <= 1e-9 * length.max(1.0); let readable = if closed { // Clockwise: the text stands on the outside. let area: f64 = pts.iter().zip(pts.iter().skip(1)).map(|(a, b)| a.cross(*b)).sum::() + last.cross(first); area <= 0.0 } else { last.x >= first.x - 1e-9 * length }; if readable == reverse { pts.reverse(); } let mut along = Vec::with_capacity(pts.len()); let mut total = 0.0; for (i, p) in pts.iter().enumerate() { if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) { total += prev.dist(*p); } along.push(total); } Ok(Path { pts, along, closed }) } fn length(&self) -> f64 { self.along.last().copied().unwrap_or(0.0) } /// Where the lines are aligned to: an open curve's start, middle or end; on a closed curve /// its top, whatever the alignment. fn anchor(&self, align: TextAlign) -> f64 { if self.closed { return self.pts.iter().zip(&self.along).max_by(|(p, _), (q, _)| p.y.total_cmp(&q.y)).map(|(_, s)| *s).unwrap_or(0.0); } match align { TextAlign::Left => 0.0, TextAlign::Center => self.length() / 2.0, TextAlign::Right => self.length(), } } /// The point at distance `s` along the curve (around a closed one; past the ends of an open /// one, straight on). fn point(&self, s: f64) -> Vec2 { let length = self.length(); let s = if self.closed { s.rem_euclid(length) } else { s }; let (first, last) = (self.pts.first().copied().unwrap_or_default(), self.pts.last().copied().unwrap_or_default()); if !self.closed && s <= 0.0 { let d = self.pts.get(1).and_then(|q| (*q - first).normalized()).unwrap_or(Vec2::X); return first + d * s; } if !self.closed && s >= length { let n = self.pts.len(); let d = n.checked_sub(2).and_then(|i| self.pts.get(i)).and_then(|q| (last - *q).normalized()).unwrap_or(Vec2::X); return last + d * (s - length); } let i = self.along.partition_point(|a| *a < s).clamp(1, self.pts.len().saturating_sub(1).max(1)); match (self.pts.get(i - 1), self.pts.get(i), self.along.get(i - 1), self.along.get(i)) { (Some(a), Some(b), Some(sa), Some(sb)) if sb > sa => *a + (*b - *a) * ((s - sa) / (sb - sa)), (Some(a), ..) => *a, _ => first, } } /// The point at `s` and the way the curve runs there, over a glyph `width` wide (the chord /// across it, so a glyph sits evenly on a bend). fn frame(&self, s: f64, width: f64) -> (Vec2, Vec2) { let h = (width / 2.0).max(self.length() * 1e-4).max(1e-9); let dir = (self.point(s + h) - self.point(s - h)).normalized().unwrap_or(Vec2::X); (self.point(s), dir) } } impl Sketch { /// The outline geometry of a text source of this sketch: its path, when it has one, is a /// curve of this sketch as it is now. pub fn text_source_geometry(&self, src: &LinkSource) -> Result> { let LinkSource::Text { text, at, height, angle, font, style } = src else { return Err(SketchError::Invalid("not a text".into())); }; let path = match &style.path { None => None, Some(id) => { let ci = self.curve_index(id).ok_or_else(|| SketchError::Invalid(format!("the text's path `{id}` is not a curve of this sketch")))?; let of_text = self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.kind == LinkKind::Text); if of_text { return Err(SketchError::Invalid("a text cannot follow the outline of a text".into())); } Some(self.polyline(ci)) } }; text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, path.as_deref()) } } /// 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)] #[path = "text_style_tests.rs"] mod style_tests; #[cfg(test)] mod tests { use super::*; #[test] fn letters_have_closed_outlines_at_the_cap_height() { let g = text_geometry("HO", Vec2::new(5.0, 0.0), 10.0, 0.0).unwrap(); assert!(!g.is_empty()); let ys: Vec = g .iter() .flat_map(|x| match *x { LinkGeom::Line(a, b) => vec![a.y, b.y], LinkGeom::Conic { a, b, .. } => vec![a.y, b.y], _ => vec![], }) .collect(); let top = ys.iter().cloned().fold(f64::MIN, f64::max); assert!((top - 10.0).abs() < 0.3, "{top}"); assert!(g.iter().any(|x| matches!(x, LinkGeom::Conic { .. })), "O has curves"); assert!(text_geometry("x", Vec2::ZERO, -1.0, 0.0).is_err()); assert!(text_geometry(&"x".repeat(2000), Vec2::ZERO, 1.0, 0.0).is_err()); // 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("../../assets/fonts/liberation-sans/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(..))); } }