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

+5
View File
@@ -108,6 +108,11 @@
//! bodies) and `ReverseNormal` (a surface body's faces turned over), each a `body` and
//! optional `faces` points. Version 17 designs need no transformation; the version stamp makes
//! 0.9.0 (which reads up to 17) ask for an update instead of meeting a feature it can't read.
//! Under the same version, a sketch text's link source can carry a `style`: `bold` and
//! `italic` (the font's bold or italic face), `align` (`left`, `center` or `right`),
//! `letter_spacing` (mm), `line_spacing` (times the font's line distance), `path` (the id of a
//! curve of the same sketch the baseline follows) and `reverse`. A text without `style` is set
//! as it always was; 0.9.0 would drop the style and set the text plain, off its curve.
use serde_json::Value;
+14 -2
View File
@@ -255,7 +255,11 @@ 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, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
LinkSource::Text { text, at, height, angle, font, style } if style.path.is_none() => {
solidcraft_sketch::text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, None)?
}
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
LinkSource::Text { .. } => return Err(DocError::Invalid("a text on a path is set with its sketch".into())),
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
@@ -586,7 +590,15 @@ fn refresh_links_in(
}
}
}
let resolved = if l.auto_face {
let resolved = if matches!(source, LinkSource::Text { .. }) {
// A text needs nothing outside its sketch; one on a path follows a curve of it.
sk.text_source_geometry(&source).map_err(DocError::from).and_then(|geom| {
if geom.is_empty() {
return Err(DocError::Invalid(format!("the {} makes no outline", source.describe())));
}
Ok(Resolved { geom, wires: Vec::new(), source: source.clone(), moved: false, face_index: None, edge_index: None })
})
} else if l.auto_face {
anchor
.filter(|anchor| {
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
+75 -15
View File
@@ -3,7 +3,14 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
() => {
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text), reverse?: bool (the other way along the path)"
};
}
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
use super::{CommandSpec, in_sketch};
@@ -84,20 +91,62 @@ 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, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
"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), ",
text_style_doc!(),
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.live(edit_text_live)
.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); live?: bool: one frame of an interactive resize or rotation (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)",
"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), ",
text_style_doc!(),
" (each kept when left out); live?: bool: one frame of an interactive change (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)",
created_doc!()
)),
];
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
/// The style a command's parameters give, over `old`.
fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
let mut style = old;
for (key, slot) in [("bold", &mut style.bold), ("italic", &mut style.italic), ("reverse", &mut style.reverse)] {
match p.get(key) {
None | Some(Value::Null) => {}
Some(Value::Bool(b)) => *slot = *b,
Some(_) => return Err(bad(cmd, format!("`{key}` must be true or false"))),
}
}
match p.get("align") {
None | Some(Value::Null) => {}
Some(Value::String(a)) => {
style.align = match a.trim().to_ascii_lowercase().as_str() {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
_ => return Err(bad(cmd, "`align` must be left, center or right")),
}
}
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
}
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
style.letter_spacing =
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
}
if p.get("line_spacing").is_some_and(|v| !v.is_null()) {
style.line_spacing =
num(p, "line_spacing").filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad(cmd, "`line_spacing` must be a positive factor"))?;
}
match p.get("path") {
None | Some(Value::Null) => {}
Some(Value::String(c)) if c.trim().is_empty() => style.path = None,
Some(Value::String(c)) => style.path = Some(c.trim().to_string()),
Some(_) => return Err(bad(cmd, "`path` must be a curve id")),
}
Ok(style)
}
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>, TextStyle)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0, s0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default()));
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"));
@@ -117,13 +166,14 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
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")),
};
let style = text_style(p, cmd, s0)?;
// A font file an agent names must lie in its folders (installed families are found by name).
if let Some(path) = font.as_deref().and_then(solidcraft_sketch::font_file_path) {
solidcraft_io::vfs::check(&path.to_string_lossy()).map_err(|e| bad(cmd, e.to_string()))?;
}
// 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 })
// An unknown font, an unreadable file or a style the font lacks is an error now, not a lost link later.
solidcraft_sketch::font_data_styled(font.as_deref(), style.bold, style.italic).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font, style })
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -135,7 +185,12 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let l = super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, src)?;
// A text needs nothing outside its sketch (a path is one of its curves).
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
let l = sk.add_link(LinkKind::Text, src, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
@@ -154,15 +209,18 @@ pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> {
/// Transform a text source as a unit; generated outlines remain linked and editable.
pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result<Vec<usize>> {
let Some(LinkSource::Text { text, at, height, angle, font }) = sk.link(link).map(|l| l.source.clone()) else {
let Some(LinkSource::Text { text, at, height, angle, font, style }) = sk.link(link).map(|l| l.source.clone()) else {
return Err(bad("sketch.move", "the selected source is not text"));
};
if style.path.is_some() {
return Err(bad("sketch.move", "a text on a path follows its curve: move the curve, or take the text off it"));
}
let q = at - center;
let (sin, cos) = rotation.sin_cos();
let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
let angle = angle + rotation;
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), &text, at, height, angle)?;
let source = LinkSource::Text { text, at, height, angle, font };
let geom = solidcraft_sketch::text_geometry_styled(font.as_deref(), &text, at, height, angle, &style, None)?;
let source = LinkSource::Text { text, at, height, angle, font, style };
let link = if copy {
sk.add_link(LinkKind::Text, source, &geom)?
} else {
@@ -205,12 +263,14 @@ fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "give `link` or `entity`")),
};
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font, style })) => (text, at, height, angle, font, style),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
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)?;
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
+9 -1
View File
@@ -135,7 +135,15 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Geom2::Text { text, at, height, angle } = t {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
if super::sketch_project::add_link(
s,
&doc,
id,
&mut sk,
LinkKind::Text,
LinkSource::Text { text, at, height, angle, font: None, style: Default::default() },
)
.is_ok()
{
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
@@ -20,7 +20,7 @@ fn text_move_updates_the_source_once_preserves_ids_and_roundtrips() {
run(&mut s, "sketch.move", json!({"entities":[curve,ids[1]],"translate":[10,-2],"center":[3,4],"angle":30}));
let sk = s.doc.sketch(1).unwrap();
assert_eq!(sk.curves.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ids);
let LinkSource::Text { at, angle, text, height, font } = &sk.link(&link).unwrap().source else { panic!() };
let LinkSource::Text { at, angle, text, height, font, .. } = &sk.link(&link).unwrap().source else { panic!() };
assert!(at.dist(Vec2::new(13.0, 2.0)) < 1e-9);
assert!((*angle - 45_f64.to_radians()).abs() < 1e-12);
assert_eq!((text.as_str(), *height, font), ("II", 10.0, &None));
@@ -150,3 +150,67 @@ fn text_move_rejects_malformed_values_without_changing_history() {
assert_eq!(s.undo.len(), undo);
}
}
/// The box around the solved outlines of text `link` in sketch 1: (min, max).
fn text_box(s: &Session, link: &str) -> (Vec2, Vec2) {
let state = s.world_state();
let sk = &state.sketch(1).unwrap().sketch;
let pts: Vec<Vec2> = sk.link_curves(link).into_iter().flat_map(|ci| sk.polyline(ci)).collect();
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
(lo, hi)
}
#[test]
fn text_on_a_path_follows_its_curve_when_the_curve_moves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
// (Away from the origin, which would hold a line's end.)
let line = run(&mut s, "sketch.line", json!({"points":[[0,-30],[80,-30]]}))["curves"][0].as_str().unwrap().to_string();
let t = run(&mut s, "sketch.text", json!({"text":"HH","at":[5,5],"height":10,"angle":30,"path":line,"align":"center"}));
let link = t["link"].as_str().unwrap().to_string();
// Centred on the line's middle, standing on it; `at` and the angle do not place it.
let (lo, hi) = text_box(&s, &link);
assert!(((lo.x + hi.x) / 2.0 - 40.0).abs() < 0.05 && (lo.y + 30.0).abs() < 1e-6 && (hi.y + 20.0).abs() < 0.05, "{lo:?} {hi:?}");
// The line moves up 20 mm: the text with it.
run(&mut s, "sketch.move", json!({"entities":[line],"translate":[0,20]}));
let (lo, hi) = text_box(&s, &link);
assert!((lo.y + 10.0).abs() < 1e-6 && ((lo.x + hi.x) / 2.0 - 40.0).abs() < 0.05, "{lo:?} {hi:?}");
// It is moved with its curve, not on its own.
let outline = s.doc.sketch(1).unwrap().curves.iter().find(|c| c.link.as_deref() == Some(link.as_str())).unwrap().id.clone();
let e = s.execute("sketch.move", &json!({"entities":[outline],"translate":[5,0]})).unwrap_err().to_string();
assert!(e.contains("follows its curve"), "{e}");
// Off the path again: at `at`, turned by its angle.
run(&mut s, "sketch.edit_text", json!({"link":link,"path":"","align":"left"}));
let (lo, _) = text_box(&s, &link);
assert!(lo.x > 0.0 && lo.y > 4.0 && lo.y < 6.0, "{lo:?}");
// A design with styled text reads back the same.
let saved = s.doc.to_json();
assert_eq!(solidcraft_doc::Document::from_json(&saved).unwrap().to_json(), saved);
}
#[test]
fn text_style_parameters_are_checked() {
let (mut s, link, _) = setup();
for (params, error) in [
(json!({"link":link,"bold":true}), "no bold face"),
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
] {
let before = s.doc.to_json();
let e = s.execute("sketch.edit_text", &params).unwrap_err().to_string();
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before);
}
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert_eq!(text, "II");
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
// Left out, a style stays.
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert!(style.italic && style.letter_spacing == 1.5);
}
+1 -1
View File
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
let text = sk
.add_link(
LinkKind::Text,
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None, style: Default::default() },
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
+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:?}");
}
+11 -4
View File
@@ -246,7 +246,7 @@ pub enum Kind {
/// Insert Part from the standard parts library (`dialogs_parts`).
Part(crate::dialogs_parts::Pt),
/// Edit Text: a sketch text's content and placement (`sketch_text`).
Text(crate::sketch_text::Tx),
Text(Box<crate::sketch_text::Tx>),
TextMove(crate::sketch_text_move::TextMove),
/// Sketch-space modify tools: scale and offset, and later fillet, chamfer, the patterns,
/// mirror and move (`sketch_modify_ui`).
@@ -544,8 +544,10 @@ impl Dialog {
inp.label = objects_label(inp.accept);
}
// Pre-selection: what is selected now becomes the input (first input that takes it); a
// face or edge stands for its body where bodies are wanted.
for sel in &s.selection {
// face or edge stands for its body where bodies are wanted. (Edit Text's selection is the
// text it edits, not its path.)
let preselection: &[Sel] = if matches!(d.kind, Kind::Text(_)) { &[] } else { &s.selection };
for sel in preselection {
let as_body = match sel {
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }),
_ => None,
@@ -1497,6 +1499,10 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
/// makes it the active input.
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
// Edit Text shows its Path input among its own rows.
if matches!(d.kind, Kind::Text(_)) {
return;
}
let t = Tokens::get();
let mut clear: Option<usize> = None;
let mut activate: Option<usize> = None;
@@ -1719,6 +1725,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
{
return;
}
crate::sketch_text::sync_path(&mut d);
let _language = crate::i18n::scope(&app.preferences.language);
if let Kind::TextMove(k) = &d.kind
&& !crate::sketch_text_move::valid(app, k)
@@ -2458,7 +2465,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k),
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k, &mut d.inputs, &mut d.active),
Kind::TextMove(k) => enter |= crate::sketch_text_move::rows(ui, k),
Kind::ConfirmDelete { with, fail, .. } => {
if !with.is_empty() {
+9 -1
View File
@@ -329,5 +329,13 @@
"sketch_text.builtin_font": "{family} (integriert)",
"sketch_text.font_search": "Schriftarten suchen",
"sketch_text.loading_fonts": "Installierte Schriftarten werden gesucht…",
"sketch_text.use_font_file": "Schriftdatei {path} verwenden"
"sketch_text.use_font_file": "Schriftdatei {path} verwenden",
"sketch_text.bold": "Fett",
"sketch_text.italic": "Kursiv",
"sketch_text.align_left": "Links",
"sketch_text.align_center": "Mitte",
"sketch_text.align_right": "Rechts",
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
"sketch_text.path_clear": "Text von der Kurve lösen",
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage"
}
+14 -1
View File
@@ -863,5 +863,18 @@
"sketch_text.builtin_font": "{family} (built-in)",
"sketch_text.font_search": "Search fonts",
"sketch_text.loading_fonts": "Looking for installed fonts…",
"sketch_text.use_font_file": "Use the font file {path}"
"sketch_text.use_font_file": "Use the font file {path}",
"sketch_text.bold": "Bold",
"sketch_text.italic": "Italic",
"sketch_text.align_left": "Left",
"sketch_text.align_center": "Center",
"sketch_text.align_right": "Right",
"sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve",
"sketch_text.path_clear": "Take the text off its curve",
"Style": "Style",
"Alignment": "Alignment",
"Letter Spacing": "Letter Spacing",
"Line Spacing": "Line Spacing",
"Path": "Path",
"sketch_text.on_path": "The text follows its path: the curve places it"
}
+14 -1
View File
@@ -863,5 +863,18 @@
"sketch_text.builtin_font": "{family}(内蔵)",
"sketch_text.font_search": "フォントを検索",
"sketch_text.loading_fonts": "インストール済みフォントを検索中…",
"sketch_text.use_font_file": "フォント ファイル {path} を使用"
"sketch_text.use_font_file": "フォント ファイル {path} を使用",
"sketch_text.bold": "太字",
"sketch_text.italic": "斜体",
"sketch_text.align_left": "左",
"sketch_text.align_center": "中央",
"sketch_text.align_right": "右",
"sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック",
"sketch_text.path_clear": "文字を曲線から外す",
"Style": "スタイル",
"Alignment": "配置",
"Letter Spacing": "文字間隔",
"Line Spacing": "行間",
"Path": "パス",
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります"
}
+9 -1
View File
@@ -329,5 +329,13 @@
"sketch_text.builtin_font": "{family}(내장)",
"sketch_text.font_search": "글꼴 검색",
"sketch_text.loading_fonts": "설치된 글꼴을 찾는 중…",
"sketch_text.use_font_file": "글꼴 파일 {path} 사용"
"sketch_text.use_font_file": "글꼴 파일 {path} 사용",
"sketch_text.bold": "굵게",
"sketch_text.italic": "기울임꼴",
"sketch_text.align_left": "왼쪽",
"sketch_text.align_center": "가운데",
"sketch_text.align_right": "오른쪽",
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다"
}
+267 -32
View File
@@ -8,18 +8,27 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Sel,
doc::expr::Kind as ValueKind,
sketch::{LinkKind, LinkSource},
sketch::{LinkKind, LinkSource, TextAlign, TextStyle},
};
use crate::SolveApp;
use crate::dialogs::{Dialog, Kind};
use crate::selection::SelInput;
/// Field rows after the text: position, height (lengths) and angle.
const LABELS: [&str; 4] = ["X", "Y", "Height", "Angle"];
const KINDS: [ValueKind; 4] = [ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle];
/// Field rows after the text: position, height (lengths), angle, letter spacing (a length) and
/// line spacing (a factor).
const LABELS: [&str; FIELDS] = ["X", "Y", "Height", "Angle", "Letter Spacing", "Line Spacing"];
const KINDS: [ValueKind; FIELDS] =
[ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle, ValueKind::Length, ValueKind::Unitless];
const FIELDS: usize = 6;
/// The field shown in degrees, and the one without a unit.
const ANGLE: usize = 3;
const LINE_SPACING: usize = 5;
/// The Edit Text dialog's state: the text being edited and its four values as expressions.
/// No input is active: a click on the canvas picks no path until the Path chip is clicked.
const NO_INPUT: usize = usize::MAX;
/// The Edit Text dialog's state: the text being edited and its values as expressions.
#[derive(Clone, Debug)]
pub struct Tx {
sketch: u64,
@@ -29,11 +38,16 @@ pub struct Tx {
/// 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],
values: [String; FIELDS],
/// The fields as first shown; an untouched field keeps its exact saved value.
initial: [String; 4],
/// The saved source: x, y, height (mm) and angle (rad).
original: [f64; 4],
initial: [String; FIELDS],
/// The saved source: x, y, height (mm), angle (rad), letter spacing (mm) and line spacing.
original: [f64; FIELDS],
/// Bold, italic, alignment, the path (the Path input's curve) and its direction as chosen.
pub(crate) style: TextStyle,
initial_style: TextStyle,
/// The Path chip was clicked: the next curve clicked on the canvas is the path.
picking: bool,
pub(crate) unit: &'static str,
/// Focus the text with all of it selected (typing replaces it).
focus: bool,
@@ -50,8 +64,8 @@ pub struct Tx {
shown: Option<Source>,
}
/// A text source as the dialog sees it: text, font (empty: built-in), x, y, height and angle.
type Source = (String, String, [f64; 4]);
/// A text source as the dialog sees it: text, font (empty: built-in), its values and its style.
type Source = (String, String, [f64; FIELDS], TextStyle);
/// What the Text tool puts down, ready to be typed over.
const PLACEHOLDER: &str = "Text";
@@ -175,7 +189,7 @@ fn shown(value: f64, unit: &str) -> String {
let text = format!("{value:.6}");
let text = text.trim_end_matches('0').trim_end_matches('.');
let text = if text == "-0" { "0" } else { text };
format!("{text} {unit}")
if unit.is_empty() { text.to_string() } else { format!("{text} {unit}") }
}
/// The saved text source behind `link` in the active sketch: its text, its font (empty: the built-in
@@ -186,23 +200,59 @@ fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<Source> {
}
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, font } = &source.source else { return None };
Some((text.clone(), font.clone().unwrap_or_default(), [at.x, at.y, *height, *angle]))
let LinkSource::Text { text, at, height, angle, font, style } = &source.source else { return None };
let values = [at.x, at.y, *height, *angle, style.letter_spacing, style.line_spacing];
Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone()))
}
fn fields(original: &[f64; 4], unit: &'static str, scale: f64) -> [String; 4] {
fn fields(original: &[f64; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] {
std::array::from_fn(|i| {
let value = original.get(i).copied().unwrap_or_default();
if i == 3 { shown(value.to_degrees(), "deg") } else { shown(value * scale, unit) }
match i {
ANGLE => shown(value.to_degrees(), "deg"),
LINE_SPACING => shown(value, ""),
_ => shown(value * scale, unit),
}
})
}
/// The dialog for a text: its Path input holds the curve the text follows; no input is active
/// until the Path chip is clicked.
fn dialog(tx: Box<Tx>) -> Dialog {
let inputs = inputs_for(&tx);
let mut d = Dialog::new(Kind::Text(tx), inputs);
d.active = NO_INPUT;
d
}
fn inputs_for(tx: &Tx) -> Vec<SelInput> {
let mut path = SelInput::new("Path", crate::selection::CURVES, false);
path.items.extend(tx.style.path.iter().map(|id| Sel::SketchCurve { id: id.clone() }));
vec![path]
}
/// Each frame, before the rows: the path is the Path input's curve; a new pick ends picking.
pub(crate) fn sync_path(d: &mut Dialog) {
let Kind::Text(k) = &mut d.kind else { return };
let picked = d.inputs.first().and_then(|i| i.items.first()).and_then(|sel| match sel {
Sel::SketchCurve { id } => Some(id.clone()),
_ => None,
});
if picked != k.style.path {
k.style.path = picked;
k.picking = false;
}
if !k.picking {
d.active = NO_INPUT;
}
}
/// The dialog's kind for the text that `entity` (an outline curve) belongs to.
fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
let sketch = app.session.active_sketch?;
let sk = app.session.doc.sketch(sketch).ok()?;
let link = sk.curves.iter().find(|curve| curve.id == entity).and_then(|curve| curve.link.clone())?;
let (text, font, original) = source(app, sketch, &link)?;
let (text, font, original, style) = source(app, sketch, &link)?;
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let values = fields(&original, unit, scale);
let mut tx = Tx {
@@ -216,6 +266,9 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
values,
original,
unit,
initial_style: style.clone(),
style,
picking: false,
focus: true,
font_filter: String::new(),
start: None,
@@ -225,7 +278,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
};
// The canvas already shows the fields as they open.
tx.tried = edit_params(app, &tx).ok();
Some(Kind::Text(tx))
Some(Kind::Text(Box::new(tx)))
}
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
@@ -233,17 +286,19 @@ pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
if id != "sketch.edit_text" {
return None;
}
Some((kind_for(app, &selected_entity(app)?)?, Vec::new()))
let Kind::Text(tx) = kind_for(app, &selected_entity(app)?)? else { return None };
let inputs = inputs_for(&tx);
Some((Kind::Text(tx), inputs))
}
/// Open the dialog for the text an outline (picked with the Edit Text tool) belongs to.
pub(crate) fn open(app: &mut SolveApp, entity: &str) {
match kind_for(app, entity) {
Some(kind) => {
app.dialog = Some(Dialog::new(kind, Vec::new()));
Some(Kind::Text(tx)) => {
app.dialog = Some(dialog(tx));
app.tool = None;
}
None => app.set_status("Choose an outline belonging to sketch text", true),
_ => app.set_status("Choose an outline belonging to sketch text", true),
}
}
@@ -258,7 +313,7 @@ pub(crate) fn place(app: &mut SolveApp, at: solidcraft_engine::geom::Vec2, heigh
Some(Kind::Text(mut tx)) => {
tx.start = Some(checkpoint);
tx.folded = true;
app.dialog = Some(Dialog::new(Kind::Text(tx), Vec::new()));
app.dialog = Some(dialog(tx));
app.tool = None;
}
_ => {
@@ -333,7 +388,13 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
if k.shown.as_ref() == Some(&current) {
return true;
}
let (text, font, original) = current;
let (text, font, original, style) = current;
if style != k.initial_style {
if k.style == k.initial_style {
k.style = style.clone();
}
k.initial_style = style;
}
if original != k.original {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let fresh = fields(&original, unit, scale);
@@ -362,8 +423,8 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
}
/// The dialog's rows; true when Enter was pressed in a value field or in the text (Shift+Enter
/// starts a new line there).
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
/// starts a new line there). `inputs` and `active`: the dialog's Path input.
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) -> bool {
let mut enter = false;
crate::dialogs::row_label(ui, "Text");
let width = ui.spacing().text_edit_width;
@@ -385,14 +446,84 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
crate::dialogs::row_label(ui, "Font");
font_row(ui, k, width);
ui.end_row();
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
crate::dialogs::row_label(ui, "Style");
ui.horizontal(|ui| {
let bold = ui.toggle_value(&mut k.style.bold, egui::RichText::new("B").strong()).on_hover_text(crate::i18n::tr("sketch_text.bold"));
crate::scenario::publish_handle("dialog:Bold", bold.rect.center());
let italic = ui.toggle_value(&mut k.style.italic, egui::RichText::new("I").italics()).on_hover_text(crate::i18n::tr("sketch_text.italic"));
crate::scenario::publish_handle("dialog:Italic", italic.rect.center());
});
ui.end_row();
crate::dialogs::row_label(ui, "Alignment");
ui.horizontal(|ui| {
for (align, id, handle) in [
(TextAlign::Left, "sketch_text.align_left", "dialog:Align left"),
(TextAlign::Center, "sketch_text.align_center", "dialog:Align center"),
(TextAlign::Right, "sketch_text.align_right", "dialog:Align right"),
] {
let response = ui.selectable_value(&mut k.style.align, align, crate::i18n::tr(id));
crate::scenario::publish_handle(handle, response.rect.center());
}
});
ui.end_row();
// On a path the curve places the text: X, Y and the angle do not.
let on_path = k.style.path.is_some();
for (i, (label, value)) in LABELS.iter().zip(k.values.iter_mut()).enumerate() {
crate::dialogs::row_label(ui, label);
enter |= crate::dialogs::field(ui, value);
let placed = on_path && matches!(i, 0 | 1 | ANGLE);
if placed {
let response = ui.add_enabled_ui(false, |ui| crate::dialogs::field(ui, value));
response.response.on_disabled_hover_text(crate::i18n::tr("sketch_text.on_path"));
} else {
enter |= crate::dialogs::field(ui, value);
}
ui.end_row();
}
path_row(ui, k, inputs, active);
enter
}
/// The curve the text follows: a chip that, clicked, takes the next curve clicked on the canvas;
/// × takes the text off it; Reverse runs it the other way along.
fn path_row(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) {
let t = crate::theme::Tokens::get();
crate::dialogs::row_label(ui, "Path");
ui.horizontal(|ui| {
let on = k.style.path.is_some();
let text = if on {
egui::RichText::new(crate::i18n::format("dialog.selected_count", &[("count", "1")])).color(egui::Color32::WHITE)
} else {
egui::RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
};
let stroke = if k.picking { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
let chip = egui::Button::new(text).fill(if on { t.chip } else { egui::Color32::TRANSPARENT }).stroke(stroke).min_size(egui::vec2(0.0, 22.0));
let chip = ui.add(chip).on_hover_text(crate::i18n::tr("sketch_text.path_hint"));
crate::scenario::publish_handle("dialog:Path", chip.rect.center());
if chip.clicked() {
k.picking = true;
*active = 0;
}
if on {
let clear = ui.small_button("\u{d7}").on_hover_text(crate::i18n::tr("sketch_text.path_clear"));
crate::scenario::publish_handle("dialog:Path clear", clear.rect.center());
if clear.clicked() {
for input in inputs.iter_mut() {
input.items.clear();
}
k.style.path = None;
k.picking = false;
}
}
});
ui.end_row();
if k.style.path.is_some() {
crate::dialogs::row_label(ui, "Reverse");
let reverse = ui.checkbox(&mut k.style.reverse, "");
crate::scenario::publish_handle("dialog:Reverse", reverse.rect.center());
ui.end_row();
}
}
/// The installed font families, listed once in the background (none on the web).
fn families(ctx: &egui::Context) -> Option<Arc<Vec<String>>> {
static FAMILIES: OnceLock<Arc<Vec<String>>> = OnceLock::new();
@@ -503,7 +634,7 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
/// `sketch.edit_text`'s parameters for the fields as they are.
fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
let doc = &app.session.doc;
let mut out = [0.0; 4];
let mut out = [0.0; FIELDS];
for (i, slot) in out.iter_mut().enumerate() {
let (Some(value), Some(initial), Some(original), Some(kind), Some(label)) =
(k.values.get(i), k.initial.get(i), k.original.get(i), KINDS.get(i), LABELS.get(i))
@@ -520,8 +651,18 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
v
};
}
let [x, y, height, angle] = out;
let mut params = json!({"link": k.link, "text": k.text, "at": [x, y], "height": height});
let [x, y, height, angle, letter_spacing, line_spacing] = out;
let align = match k.style.align {
TextAlign::Left => "left",
TextAlign::Center => "center",
TextAlign::Right => "right",
};
let mut params = json!({
"link": k.link, "text": k.text, "at": [x, y], "height": height,
"bold": k.style.bold, "italic": k.style.italic, "align": align,
"letter_spacing": letter_spacing, "line_spacing": line_spacing,
"path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse,
});
// Omitting an unchanged angle keeps its exact saved radians.
if k.values.get(3) != k.initial.get(3) {
params["angle"] = json!(angle.to_degrees());
@@ -670,6 +811,7 @@ mod tests {
height: 20.0,
angle: std::f64::consts::FRAC_PI_2,
font: None,
style: TextStyle::default(),
};
assert_eq!(sk.link(&link).unwrap().source, expected);
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
@@ -697,7 +839,7 @@ mod tests {
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, font: None }
LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::default() }
);
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");
@@ -1105,4 +1247,97 @@ mod tests {
h.frames(2);
assert_eq!(h.app.session.selection.len(), 2);
}
/// The style of text `link` in the active sketch.
fn style_of(h: &Harness, link: &str) -> TextStyle {
let sid = h.app.session.active_sketch.unwrap();
match &h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source {
LinkSource::Text { style, .. } => style.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn style_alignment_and_spacing_show_live_and_apply_in_one_step() {
let (mut h, link, _) = setup();
let undo = h.app.session.undo.len();
h.app.start("sketch.edit_text");
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Italic"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Align center"}})).unwrap();
replace(&mut h, "dialog:Letter Spacing", "1.5");
replace(&mut h, "dialog:Line Spacing", "1.25");
h.frames(2);
// On the canvas before OK.
let live = style_of(&h, &link);
assert!(live.italic && !live.bold && live.align == TextAlign::Center, "{live:?}");
assert_eq!((live.letter_spacing, live.line_spacing), (1.5, 1.25));
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(style_of(&h, &link), live);
assert_eq!(h.app.session.undo.len(), undo + 1);
// Reopened, the dialog shows them.
let sid = h.app.session.active_sketch.unwrap();
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);
assert_eq!(tx(&h.app).style, live);
assert_eq!(tx(&h.app).values[4], "1.5 mm");
assert_eq!(tx(&h.app).values[5], "1.25");
}
#[test]
fn bold_in_a_font_without_a_bold_face_is_refused_with_the_reason() {
let (mut h, link, _) = setup();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
h.app.start("sketch.edit_text");
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Bold"}})).unwrap();
h.frames(2);
assert!(!style_of(&h, &link).bold, "the canvas keeps the last text it could make");
ok(&mut h);
assert!(editing(&h.app));
let error = h.app.dialog.as_ref().unwrap().error.clone().unwrap_or_default();
assert!(error.contains("no bold face"), "{error}");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
}
#[test]
fn the_path_chip_takes_the_next_curve_clicked_and_the_text_follows_it() {
let (mut h, link, curve) = setup();
let line = h.app.run("sketch.line", json!({"points":[[-20,-30],[40,-30]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 120.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
h.app.session.selection = vec![Sel::SketchCurve { id: curve }];
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(style_of(&h, &link).path, None, "the selected text is not its own path");
// A click on the canvas before the chip picks nothing.
h.step(&json!({"click":{"sketch":[10,-30]}})).unwrap();
h.frames(2);
assert_eq!(tx(&h.app).style.path, None);
// The chip, then the line: the text stands on it at once.
h.step(&json!({"click":{"handle":"dialog:Path"}})).unwrap();
h.step(&json!({"click":{"sketch":[10,-30]}})).unwrap();
h.frames(3);
assert_eq!(tx(&h.app).style.path.as_deref(), Some(line.as_str()));
assert_eq!(style_of(&h, &link).path.as_deref(), Some(line.as_str()));
let state = h.app.session.world_state();
let sk = &state.sketch(sid).unwrap().sketch;
let low = sk.link_curves(&link).into_iter().flat_map(|ci| sk.polyline(ci)).map(|p| p.y).fold(f64::MAX, f64::min);
assert!((low + 30.0).abs() < 1e-6, "on the line: {low}");
// Reverse, then OK: one undo step.
h.step(&json!({"click":{"handle":"dialog:Reverse"}})).unwrap();
h.frames(2);
assert!(style_of(&h, &link).reverse);
// × takes it off again.
h.step(&json!({"click":{"handle":"dialog:Path clear"}})).unwrap();
h.frames(2);
assert_eq!(style_of(&h, &link).path, None);
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!(style_of(&h, &link), TextStyle::default());
}
}
+5 -1
View File
@@ -105,7 +105,11 @@ fn frame(app: &SolveApp, link: &str) -> Option<Frame> {
let state = app.session.world_state();
let sk = &state.sketch(sid)?.sketch;
let source = sk.link(link).filter(|l| l.kind == LinkKind::Text)?;
let LinkSource::Text { at, height, angle, .. } = &source.source else { return None };
let LinkSource::Text { at, height, angle, style, .. } = &source.source else { return None };
// A text on a path follows its curve: it is not placed by hand.
if style.path.is_some() {
return None;
}
let mut f = Frame { link: link.to_owned(), at: *at, height: *height, angle: *angle, min: Vec2::ZERO, max: Vec2::ZERO };
let mut first = true;
for p in sk.link_curves(link).into_iter().flat_map(|ci| sk.polyline(ci)) {