feat: sketch text takes a font, an installed family or a font file (#516)
sketch.text and sketch.edit_text take an optional font: an installed family or a .ttf/.otf/.ttc file. Edit Text gets a Font field. The font is kept with the text under format 16; no fonts are bundled, and the font search is bounded. Fixes #513 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d00df0a3f
commit
a69c2928f0
15 files changed
+883
-41
No files matched your search
@@ -80,16 +80,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
|
||||
.params(concat!(
|
||||
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
|
||||
created_doc!()
|
||||
)),
|
||||
];
|
||||
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
|
||||
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, "`text` must not be empty"));
|
||||
@@ -103,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
|
||||
return Err(bad(cmd, "`height` must be positive"));
|
||||
}
|
||||
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
|
||||
Ok(LinkSource::Text { text, at, height, angle })
|
||||
let font = match p.get("font") {
|
||||
None | Some(Value::Null) => f0,
|
||||
Some(Value::String(f)) if f.trim().is_empty() => None,
|
||||
Some(Value::String(f)) => Some(f.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
|
||||
};
|
||||
// An unknown font or an unreadable file is an error now, not a lost link later.
|
||||
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(LinkSource::Text { text, at, height, angle, font })
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -133,12 +144,12 @@ fn edit_text(s: &mut Session, 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 })) => (text, at, height, angle),
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
|
||||
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
|
||||
};
|
||||
let src = text_source(p, cmd, Some(old))?;
|
||||
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
|
||||
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?;
|
||||
sk.set_link_source(&l, src.clone());
|
||||
sk.update_link(&l, &geom)?;
|
||||
let curves = ids_of(&sk, &sk.link_curves(&l));
|
||||
|
||||
@@ -135,7 +135,8 @@ 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 }).is_ok() {
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
|
||||
{
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
n_text += 1;
|
||||
|
||||
@@ -495,6 +495,47 @@ fn text_embossed_or_debossed_leaves_its_counters_alone() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
|
||||
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
|
||||
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
|
||||
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
|
||||
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
|
||||
let near = |s: &Session, want: f64| {
|
||||
let a = profiles(s);
|
||||
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
|
||||
};
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
|
||||
near(&s, 13.5557);
|
||||
let link = r["link"].as_str().unwrap().to_string();
|
||||
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
|
||||
near(&s, 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
|
||||
near(&s, 4.0 * 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
|
||||
near(&s, 4.0 * 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// The font is kept in the design: after saving and reopening, the text is still in it.
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
|
||||
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
|
||||
// A new text takes the font too. Unknown fonts and values that are not names are errors.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
|
||||
near(&s, 17.6385);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_curve_joins_tangentially() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
Reference in new issue
Block a user