Merge pull request #4250 from DCaioP/fix/4128-text-create-installed-font-style

Fix #4128: text.create and text.setStyle store a font style the family has
This commit is contained in:
BFlat
2026-10-26 18:16:53 +07:00
committed by GitHub
5 changed files with 79 additions and 9 deletions
+4 -1
View File
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -794,6 +794,9 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
}
if let Some(f) = str_param(p, "style") {
style.font_style = f.to_string();
} else if str_param(p, "font").is_some() {
// A family without a Regular (#4128): its closest style, as text.setRangeStyle takes.
super::textedit::fit_font_style(&mut style);
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
+15 -7
View File
@@ -274,6 +274,20 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"text": text, "runs": runs, "length": len}))
}
/// Keep `st`'s font style when its family has it, else take the family's closest one: a family
/// whose styles are numbered (W3, W6) has no Regular (#4128). A family that isn't installed keeps
/// the style as it is.
pub(crate) fn fit_font_style(st: &mut CharStyle) {
let db = vectorcraft_text::FontDb::global();
let styles = db.styles(&st.font_family);
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = db.face(&st.font_family, &st.font_style)
{
st.font_style = face.style.clone();
}
}
/// Character attribute changes parsed from params (shared by range and whole-object styling).
#[derive(Default)]
pub(crate) struct CharChange {
@@ -540,13 +554,7 @@ impl CharChange {
st.font_family = f.clone();
// Keep the style when the new family has it, else its closest match.
if self.style.is_none() {
let styles = vectorcraft_text::FontDb::global().styles(f);
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = vectorcraft_text::FontDb::global().face(f, &st.font_style)
{
st.font_style = face.style.clone();
}
fit_font_style(st);
}
}
if let Some(v) = &self.style {
+8 -1
View File
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_style
),
@@ -811,6 +811,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
style_chars(t, range, |st| {
if let Some(f) = &font {
st.font_family = f.clone();
// Keep the style when the new family has it, else its closest match (#4128).
if style.is_none() {
super::textedit::fit_font_style(st);
}
}
if let Some(f) = &style {
st.font_style = f.clone();
@@ -853,6 +857,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.font_family = f.clone();
st.font_version = None;
st.font_axes.clear();
if style.is_none() {
super::textedit::fit_font_style(st);
}
}
if let Some(f) = &style {
st.font_style = f.clone();
+29
View File
@@ -1427,3 +1427,32 @@ fn clicking_the_in_port_then_the_canvas_threads_a_new_frame_before_it() {
let strokes = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { .. })).count();
assert_eq!(strokes, 6, "both frames selected: the first one's out port and the second one's in port show arrows");
}
/// Given a family without a style, text.create, text.createInPath and text.setStyle keep the style
/// when the family has it, else take the family's closest one, as text.setRangeStyle does: a family
/// whose styles are numbered has no Regular to store (#4128). A style given is kept as given.
#[test]
fn a_family_without_its_style_takes_its_closest_style() {
use vectorcraft_text::test_fonts::{NUMBERED_FAMILY as FAMILY, numbered_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(numbered_font("W3").unwrap());
db.add_font(numbered_font("W6").unwrap());
let numbered = ["W3", "W6"];
let style = |s: &Session, id: NodeId| obj(s, id).runs[0].style.font_style.clone();
let create = |s: &mut Session, p: Value| NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap());
let mut s = session();
let created = create(&mut s, json!({"x": 10, "y": 50, "text": "a", "font": FAMILY}));
assert!(numbered.contains(&style(&s, created).as_str()), "text.create: {}", style(&s, created));
let pid = s.execute("path.create", &json!({"d": "M100 400 L400 100"})).unwrap()["id"].as_u64().unwrap();
let on_path = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "a", "font": FAMILY})).unwrap();
let on_path = NodeId(on_path["id"].as_u64().unwrap());
assert!(numbered.contains(&style(&s, on_path).as_str()), "text.createInPath: {}", style(&s, on_path));
let given = create(&mut s, json!({"x": 10, "y": 90, "text": "a", "font": FAMILY, "style": "Medium"}));
assert_eq!(style(&s, given), "Medium", "a style given is kept");
let set = text(&mut s, "a");
s.execute("text.setStyle", &json!({"ids": [set.0], "font": FAMILY})).unwrap();
assert!(numbered.contains(&style(&s, set).as_str()), "text.setStyle: {}", style(&s, set));
s.execute("text.setStyle", &json!({"ids": [set.0], "style": "W6"})).unwrap();
s.execute("text.setStyle", &json!({"ids": [set.0], "font": FAMILY})).unwrap();
assert_eq!(style(&s, set), "W6", "a style the family has is kept");
}
+23
View File
@@ -408,6 +408,29 @@ pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`numbered_font`]: styles numbered W3 and W6, and no Regular.
pub const NUMBERED_FAMILY: &str = "Numtest Mincho";
/// A face of family [`NUMBERED_FAMILY`] in `style` (`W3` or `W6`; the bundled Source Sans 3
/// Regular renamed), as families whose styles are numbered have, with no Regular among them.
/// `None` when it can't be built.
pub fn numbered_font(style: &str) -> Option<Vec<u8>> {
const FACE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(FACE).ok()?;
let full = format!("{NUMBERED_FAMILY} {style}");
let postscript = format!("NumtestMincho-{style}");
let name = name_table(&[(1, NUMBERED_FAMILY), (2, style), (4, &full), (5, "Version 1.000"), (6, &postscript)])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The character whose glyph [`colour_bitmap_font`] draws as a colour bitmap.
pub const COLOUR_CHAR: char = 'E';