Sketch text: bold and italic, text along a curve, alignment and spacing (#852)

A sketch text gets an optional style: bold and italic faces (a slanted fallback for italic), left/center/right alignment, letter and line spacing, and a path curve of the same sketch for the baseline to follow (reverse takes the other side). Edit Text gets B/I toggles, alignment, spacing fields and a Path chip, all live. The style is saved only when not plain and is noted under format 18.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-13 19:27:35 -04:00
1 parent fe3717f2e4
commit dd7790a43f
21 files changed
+1082 -100

No files matched your search

+9 -2
View File
@@ -223,7 +223,7 @@ 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 source = LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()), style: Default::default() };
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.
@@ -296,7 +296,14 @@ fn a_text_that_the_font_makes_too_large_is_refused() {
#[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) };
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),
style: Default::default(),
};
// 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();
+91 -15
View File
@@ -69,8 +69,47 @@ pub fn font_data(name: Option<&str>) -> Result<FontData> {
.ok_or_else(|| SketchError::Invalid(format!("unknown font `{name}`: not an installed font family, and not a .ttf/.otf file path")))
}
/// The face of a font for a style: the bold and/or italic face of an installed family (or the
/// file or built-in font itself). True with it: the font has no italic face, so the text is to
/// be slanted instead. A bold style the font does not have is an error (bold is not made up).
pub fn font_data_styled(name: Option<&str>, bold: bool, italic: bool) -> Result<(FontData, bool)> {
let trimmed = name.map(str::trim).unwrap_or("");
let installed = !(trimmed.is_empty() || normalize(trimmed) == normalize(DEFAULT_FONT_FAMILY) || is_path(trimmed));
if !installed || !(bold || italic) {
let data = font_data(name)?;
let (face_bold, face_italic) = ttf_parser::Face::parse(data.bytes(), data.index)
.map(|face| (face.is_bold(), face.is_italic() || face.is_oblique()))
.map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
if bold && !face_bold {
let which = if trimmed.is_empty() || normalize(trimmed) == normalize(DEFAULT_FONT_FAMILY) {
"the built-in font".to_string()
} else {
format!("the font file `{trimmed}`")
};
return Err(SketchError::Invalid(format!("{which} has no bold face: choose an installed font, or its bold file")));
}
return Ok((data, italic && !face_italic));
}
if trimmed.len() > MAX_FONT_NAME {
return Err(SketchError::Invalid("the font name is too long".into()));
}
let Some((data, face_bold, face_italic)) = find_style_in(&font_dirs(), trimmed, bold, italic)? else {
return Err(SketchError::Invalid(format!("unknown font `{trimmed}`: not an installed font family, and not a .ttf/.otf file path")));
};
if bold && !face_bold {
return Err(SketchError::Invalid(format!("the font `{trimmed}` has no bold face")));
}
Ok((data, italic && !face_italic))
}
/// 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>> {
Ok(find_style_in(dirs, name, false, false)?.map(|(data, _, _)| data))
}
/// The installed face of `name` (a family, full or PostScript name) nearest to the style, in
/// `dirs`, with whether it is bold and italic.
pub fn find_style_in(dirs: &[PathBuf], name: &str, bold: bool, italic: bool) -> Result<Option<(FontData, bool, bool)>> {
let want = normalize(name);
if want.is_empty() {
return Ok(None);
@@ -83,21 +122,21 @@ pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result<Option<FontData>>
stem.len() >= 3 && (stem.contains(&want) || want.contains(&stem))
});
for group in [likely, rest] {
let mut best: Option<(u32, PathBuf, u32)> = None;
let mut best: Option<(u32, PathBuf, u32, bool, bool)> = 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));
let score = face.score(&want, bold, italic);
if score > 0 && best.as_ref().is_none_or(|b| score > b.0) {
best = Some((score, path.clone(), face.index, face.bold, face.italic));
}
}
if best.as_ref().is_some_and(|(s, _, _)| *s >= 4) {
if best.as_ref().is_some_and(|b| b.0 >= FULL_NAME_SCORE) {
break;
}
}
if let Some((_, path, index)) = best {
if let Some((_, path, index, face_bold, face_italic)) = 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 }));
return Ok(Some((FontData { bytes: FontBytes::File(bytes), index }, face_bold, face_italic)));
}
}
Ok(None)
@@ -189,22 +228,30 @@ fn family_name(table: &ttf_parser::name::Table) -> Option<String> {
/// The names of one face of a font file (normalised), for matching.
struct FaceNames {
index: u32,
regular: bool,
bold: bool,
italic: bool,
names: Vec<(u16, String)>,
}
/// A face named by its full or PostScript name: that face, whatever the style asked for.
const FULL_NAME_SCORE: u32 = 40;
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 {
/// How well the face answers to a (normalised) name in a style: its full or PostScript
/// name in that style, else its family's face of that style, else (lower) another face of
/// the family, bold matching first, else its full name in another style (a regular face's
/// full name is often the family's: "Arial" is not the bold Arial); 0 no match.
fn score(&self, want: &str, bold: bool, italic: bool) -> u32 {
use ttf_parser::name_id;
let style =
if self.bold == bold && self.italic == italic { 30 } else { 20 + 2 * u32::from(self.bold == bold) + u32::from(self.italic == italic) };
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,
name_id::FULL_NAME | name_id::POST_SCRIPT_NAME if self.bold == bold && self.italic == italic => FULL_NAME_SCORE,
name_id::FULL_NAME | name_id::POST_SCRIPT_NAME => 10,
name_id::FAMILY | name_id::TYPOGRAPHIC_FAMILY => style,
_ => 0,
})
.max()
@@ -242,7 +289,7 @@ fn face_names(path: &Path) -> Arc<Vec<FaceNames>> {
Some((n.name_id, normalize(&text)))
})
.collect();
faces.push(FaceNames { index, regular: face.is_regular() || (!face.is_bold() && !face.is_italic()), names });
faces.push(FaceNames { index, bold: face.is_bold(), italic: face.is_italic() || face.is_oblique(), names });
}
}
let faces = Arc::new(faces);
@@ -401,3 +448,32 @@ fn read_font(path: &Path) -> std::result::Result<Arc<Vec<u8>>, String> {
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use ttf_parser::name_id;
/// A face of family "Arial" whose full name is `full`.
fn face(full: &str, bold: bool, italic: bool) -> FaceNames {
FaceNames { index: 0, bold, italic, names: vec![(name_id::FAMILY, normalize("Arial")), (name_id::FULL_NAME, normalize(full))] }
}
#[test]
fn a_style_picks_its_face_even_when_the_regular_face_is_named_like_the_family() {
// As installed: the regular face's full name is the family's own.
let faces =
[face("Arial", false, false), face("Arial Bold", true, false), face("Arial Italic", false, true), face("Arial Bold Italic", true, true)];
let best = |bold: bool, italic: bool| {
let scores: Vec<u32> = faces.iter().map(|f| f.score("arial", bold, italic)).collect();
let top = scores.iter().copied().max().unwrap_or(0);
faces.iter().zip(&scores).find(|(_, s)| **s == top).map(|(f, _)| (f.bold, f.italic))
};
for style in [(false, false), (true, false), (false, true), (true, true)] {
assert_eq!(best(style.0, style.1), Some(style), "{style:?}");
}
// A full name still finds its face, whatever style is asked (it is the only one by that name).
assert!(face("Arial Bold", true, false).score("arialbold", false, false) > 0);
assert_eq!(face("Arial", false, false).score("times", false, false), 0);
}
}
+2 -2
View File
@@ -42,7 +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, families_in, font_data, font_file_path, installed_families};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, font_file_path, installed_families};
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};
@@ -58,7 +58,7 @@ pub use solver::{
Analysis, CONSISTENT, DrivingValues, Hold, SolveReport, SolveStatus, constraint_residual, determined_by, measure_dimension, solve,
solve_after_edit, solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, text_geometry, text_geometry_in};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)]
+3
View File
@@ -81,6 +81,9 @@ pub enum LinkSource {
angle: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
font: Option<String>,
/// Bold or italic, alignment, spacing and the curve the text follows.
#[serde(default, skip_serializing_if = "crate::text::TextStyle::is_plain")]
style: crate::text::TextStyle,
},
}
+1 -1
View File
@@ -650,7 +650,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, font: None };
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default() };
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
sk
}
+290 -18
View File
@@ -6,11 +6,12 @@
//! 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 serde::{Deserialize, Serialize};
use solidcraft_geom::Vec2;
use crate::fonts::font_data;
use crate::link::{LinkGeom, MAX_LINK_ENTITIES};
use crate::model::{Result, SketchError};
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;
@@ -80,6 +81,85 @@ impl ttf_parser::OutlineBuilder for Outline {
}
}
/// 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<String>,
/// 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<Vec<LinkGeom>> {
text_geometry_in(None, text, at, height, angle)
@@ -89,43 +169,231 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
/// 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>> {
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<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 data = font_data(font)?;
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 cap = cap_height(&face);
let k = height / cap;
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 };
let (s, c) = angle.sin_cos();
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, line) in text.split('\n').enumerate() {
let mut pen = 0.0f64;
for (row, (glyphs, width)) in lines.iter().enumerate() {
let base_y = -(row as f64) * line_gap;
for ch in line.chars() {
let Some(gid) = face.glyph_index(ch).or_else(|| face.glyph_index('?')) else { continue };
let x0 = pen;
let map = Box::new(move |x: f32, y: f32| {
let (lx, ly) = (x0 + f64::from(x) * k, base_y + f64::from(y) * k);
at + Vec2::new(lx * c - ly * s, lx * s + ly * c)
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: at, cur: at, map };
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);
}
pen += f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k;
}
}
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<Vec2>,
along: Vec<f64>,
closed: bool,
}
impl Path {
fn new(points: &[Vec2], reverse: bool) -> Result<Path> {
let mut pts: Vec<Vec2> = 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::<f64>() + 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<Vec<LinkGeom>> {
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 {
@@ -136,6 +404,10 @@ fn cap_height(face: &ttf_parser::Face) -> f64 {
.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::*;
+187
View File
@@ -0,0 +1,187 @@
//! Text styles: alignment, letter and line spacing, a made-up italic, a bold the font lacks, and
//! text that follows a curve of its sketch.
use solidcraft_geom::Vec2;
use super::*;
use crate::{CurveKind, Sketch};
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// Every end and control point of the outlines.
fn points(g: &[LinkGeom]) -> Vec<Vec2> {
g.iter()
.flat_map(|x| match x {
LinkGeom::Line(a, b) => vec![*a, *b],
LinkGeom::Conic { a, apex, b, .. } => vec![*a, *apex, *b],
LinkGeom::Spline { pts, .. } => pts.clone(),
_ => vec![],
})
.collect()
}
/// The box around the outlines' end points: (min, max).
fn extent(g: &[LinkGeom]) -> (Vec2, Vec2) {
let pts: Vec<Vec2> = g
.iter()
.flat_map(|x| match x {
LinkGeom::Line(a, b) | LinkGeom::Conic { a, b, .. } => vec![*a, *b],
_ => vec![],
})
.collect();
let lo = pts.iter().fold(v(f64::MAX, f64::MAX), |m, p| v(m.x.min(p.x), m.y.min(p.y)));
let hi = pts.iter().fold(v(f64::MIN, f64::MIN), |m, p| v(m.x.max(p.x), m.y.max(p.y)));
(lo, hi)
}
fn styled(text: &str, style: &TextStyle, path: Option<&[Vec2]>) -> Vec<LinkGeom> {
text_geometry_styled(None, text, Vec2::ZERO, 10.0, 0.0, style, path).unwrap()
}
#[test]
fn the_plain_style_is_the_text_as_before() {
assert!(TextStyle::default().is_plain());
assert_eq!(styled("Ab\nC", &TextStyle::default(), None), text_geometry("Ab\nC", Vec2::ZERO, 10.0, 0.0).unwrap());
}
#[test]
fn lines_align_left_center_or_right_on_the_anchor() {
let left = extent(&styled("HH", &TextStyle::default(), None));
let width = left.1.x - left.0.x;
let center = extent(&styled("HH", &TextStyle { align: TextAlign::Center, ..TextStyle::default() }, None));
let right = extent(&styled("HH", &TextStyle { align: TextAlign::Right, ..TextStyle::default() }, None));
// H has the same side bearing both sides: centred, the ink is about the anchor; right, it ends left of it.
assert!((center.0.x + center.1.x).abs() < 0.05, "{center:?}");
assert!((right.1.x - right.0.x - width).abs() < 1e-9 && right.1.x < 0.0 && right.1.x > -2.0, "{right:?}");
assert!((left.0.y - center.0.y).abs() < 1e-12, "alignment moves lines along, not up");
}
#[test]
fn letter_spacing_adds_between_letters_and_line_spacing_scales_the_lines() {
let plain = extent(&styled("HHH", &TextStyle::default(), None));
let spaced = extent(&styled("HHH", &TextStyle { letter_spacing: 2.5, ..TextStyle::default() }, None));
assert!(((spaced.1.x - spaced.0.x) - (plain.1.x - plain.0.x) - 5.0).abs() < 1e-9, "two gaps of 2.5 mm");
let one = extent(&styled("H\nH", &TextStyle::default(), None));
let double = extent(&styled("H\nH", &TextStyle { line_spacing: 2.0, ..TextStyle::default() }, None));
// The second line's baseline: one line distance below the first, then two.
let gap = -one.0.y;
assert!((-double.0.y - 2.0 * gap).abs() < 1e-9, "{gap} {double:?}");
for bad in [TextStyle { line_spacing: 0.0, ..TextStyle::default() }, TextStyle { letter_spacing: f64::NAN, ..TextStyle::default() }] {
assert!(text_geometry_styled(None, "H", Vec2::ZERO, 10.0, 0.0, &bad, None).is_err());
}
}
#[test]
fn italic_without_an_italic_face_slants_and_bold_without_a_bold_face_is_an_error() {
// The built-in font has neither: its I leans 12 degrees, its top moved right by the height times tan 12°.
let upright = extent(&styled("I", &TextStyle::default(), None));
let slanted = extent(&styled("I", &TextStyle { italic: true, ..TextStyle::default() }, None));
assert!((slanted.1.x - upright.1.x - 10.0 * 12_f64.to_radians().tan()).abs() < 1e-6, "{upright:?} {slanted:?}");
assert!((slanted.0.x - upright.0.x).abs() < 1e-9, "the baseline stays");
let e = text_geometry_styled(None, "I", Vec2::ZERO, 10.0, 0.0, &TextStyle { bold: true, ..TextStyle::default() }, None).unwrap_err();
assert!(e.to_string().contains("no bold face"), "{e}");
}
#[test]
fn text_on_a_straight_path_stands_on_it_reading_left_to_right_whichever_way_it_was_drawn() {
let plain = styled("Hi", &TextStyle::default(), None);
let on = |path: &[Vec2], reverse: bool| styled("Hi", &TextStyle { path: Some("l".into()), reverse, ..TextStyle::default() }, Some(path));
let same = |a: &[LinkGeom], b: &[LinkGeom]| points(a).iter().zip(points(b)).all(|(p, q)| p.dist(q) < 1e-9);
// Along the x axis from the origin: the text as it is at the origin.
assert!(same(&on(&[v(0.0, 0.0), v(100.0, 0.0)], false), &plain));
// The same line drawn right to left still reads left to right, from its left end.
assert!(same(&on(&[v(100.0, 0.0), v(0.0, 0.0)], false), &plain));
// Reversed: right to left from the right end, upside down below the line.
let (lo, hi) = extent(&on(&[v(0.0, 0.0), v(100.0, 0.0)], true));
assert!(hi.y <= 1e-9 && lo.y < -5.0 && hi.x <= 100.0 + 1e-9 && lo.x > 80.0, "{lo:?} {hi:?}");
// Centred on the path's middle, ended at its end.
let c = extent(&styled(
"HH",
&TextStyle { path: Some("l".into()), align: TextAlign::Center, ..TextStyle::default() },
Some(&[v(0.0, 0.0), v(100.0, 0.0)]),
));
assert!(((c.0.x + c.1.x) / 2.0 - 50.0).abs() < 0.05, "{c:?}");
}
#[test]
fn text_on_a_circle_stands_outside_it_centred_on_top_and_inside_reversed() {
let r = 30.0;
let circle: Vec<Vec2> = (0..=360).map(|i| v(r * (i as f64).to_radians().cos(), r * (i as f64).to_radians().sin())).collect();
let style = TextStyle { path: Some("c".into()), align: TextAlign::Center, ..TextStyle::default() };
let outside = styled("TOP", &style, Some(&circle));
// Every outline point is outside the circle (but for the O dipping below its baseline), and the text is about the top.
assert!(points(&outside).iter().all(|p| p.len() >= r - 0.3), "outside");
let (lo, hi) = extent(&outside);
// (The ink is not quite symmetric: the P has a wide right side bearing.)
assert!((lo.x + hi.x).abs() < 2.0 && lo.y > 0.0, "{lo:?} {hi:?}");
let inside = styled("TOP", &TextStyle { reverse: true, ..style }, Some(&circle));
assert!(points(&inside).iter().all(|p| p.len() <= r + 0.3), "inside");
}
#[test]
fn a_sketch_sets_its_text_on_one_of_its_curves() {
let mut sk = Sketch::new();
let a = sk.add_point(v(0.0, 0.0), None).unwrap();
let b = sk.add_point(v(80.0, 0.0), None).unwrap();
let line = sk.add_curve(CurveKind::Line { a, b }, None).unwrap();
let id = sk.curves[line].id.clone();
let source = |path: &str| LinkSource::Text {
text: "Hi".into(),
at: v(5.0, 5.0),
height: 10.0,
angle: 0.7,
font: None,
style: TextStyle { path: Some(path.into()), ..TextStyle::default() },
};
// On the line: `at` and the angle do not place it.
let on = sk.text_source_geometry(&source(&id)).unwrap();
assert_eq!(on, styled("Hi", &TextStyle { path: Some("x".into()), ..TextStyle::default() }, Some(&[v(0.0, 0.0), v(80.0, 0.0)])));
let e = sk.text_source_geometry(&source("nope")).unwrap_err().to_string();
assert!(e.contains("not a curve of this sketch"), "{e}");
// A text cannot follow its own (or another text's) outline.
let text = sk.add_link(LinkKind::Text, source(&id), &on).unwrap();
let outline = sk.curves.iter().find(|c| c.link.as_deref() == Some(text.as_str())).unwrap().id.clone();
let e = sk.text_source_geometry(&source(&outline)).unwrap_err().to_string();
assert!(e.contains("outline of a text"), "{e}");
}
#[test]
fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
let plain = LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 5.0, angle: 0.0, font: None, style: TextStyle::default() };
let json = serde_json::to_value(&plain).unwrap();
assert!(json.get("style").is_none(), "{json}");
let styled = LinkSource::Text {
text: "A".into(),
at: Vec2::ZERO,
height: 5.0,
angle: 0.0,
font: None,
style: TextStyle {
bold: true,
italic: true,
align: TextAlign::Right,
letter_spacing: -0.5,
line_spacing: 1.5,
path: Some("l3".into()),
reverse: true,
},
};
let json = serde_json::to_value(&styled).unwrap();
assert_eq!(json["style"]["align"], "right");
assert_eq!(serde_json::from_value::<LinkSource>(json).unwrap(), styled);
// Saved without a style (before styles): plain.
let mut old = serde_json::to_value(&plain).unwrap();
old.as_object_mut().unwrap().remove("style");
assert_eq!(serde_json::from_value::<LinkSource>(old).unwrap(), plain);
}
#[test]
fn on_a_closed_curve_left_alignment_starts_at_the_top() {
let r = 30.0;
let circle: Vec<Vec2> = (0..=360).map(|i| v(r * (i as f64).to_radians().cos(), r * (i as f64).to_radians().sin())).collect();
let (lo, hi) = extent(&styled("START", &TextStyle { path: Some("c".into()), ..TextStyle::default() }, Some(&circle)));
// It runs clockwise from the top: to the right of it, coming down.
assert!(lo.x > -1.0 && hi.x > 10.0 && hi.y > r, "{lo:?} {hi:?}");
}