M4: weight steps follow the family's order and keep widths and oblique slants (#1831)

Review follow-up. The step now finds the chars' style in the family's list
(the style menu's order, lightest first) and takes its neighbour, so a style
whose name gives no distinct weight (Semilight) is reachable; a style the
family lacks or a custom wght value still goes by weight, ties going to the
nearer one in that order. "Extra", "Ultra", "Semi" and "Demi" are weight
words only before a weight ("Extra Bold"), so "Extra Condensed" keeps its
width; Oblique names count as slanted for the current style too; W3-style
names are weights; and style_weight reads Hairline as 100, not 400. The
installed-font test picks a family the step actually moves.
This commit is contained in:
ljkx committed 2026-11-03 00:49:00 +01:00
1 parent 6365e4339b
commit dc82e6cac9
4 files changed
+86 -23

No files matched your search

+1
View File
@@ -223,6 +223,7 @@ fn text_case() {
assert_eq!(style_weight("Semi Bold Italic"), 600);
assert_eq!(style_weight("Black"), 900);
assert_eq!(style_weight("Regular"), 400);
assert_eq!(style_weight("Hairline"), 100);
}
#[test]
+2 -1
View File
@@ -389,8 +389,9 @@ pub fn style_weight(style: &str) -> u16 {
{
return n;
}
let table: [(&str, u16); 10] = [
let table: [(&str, u16); 11] = [
("thin", 100),
("hairline", 100),
("extralight", 200),
("ultralight", 200),
("light", 300),
+49 -16
View File
@@ -122,14 +122,23 @@ fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// What a style name says besides its weight and slant ("Narrow Bold Italic" → "narrow").
/// Hiragino's weight names: "W3" is 300.
fn w_number(word: &str) -> Option<u16> {
let n = word.strip_prefix('w')?.parse::<u16>().ok()?;
(1..=9).contains(&n).then_some(n * 100)
}
/// What a style name says besides its weight and slant ("Narrow Bold Italic" → "narrow",
/// "Extra Condensed Bold" → "extra condensed").
fn style_rest(style: &str) -> String {
const WORDS: [&str; 25] = [
const WEIGHTS: [&str; 25] = [
"thin",
"hairline",
"extralight",
"ultralight",
"light",
"semilight",
"demilight",
"regular",
"normal",
"roman",
@@ -141,40 +150,64 @@ fn style_rest(style: &str) -> String {
"extrabold",
"ultrabold",
"black",
"extrablack",
"ultrablack",
"heavy",
"semi",
"demi",
"extra",
"ultra",
"italic",
"oblique",
"plain",
"standard",
];
// Words that grade the weight word after them ("Extra Bold") but a width otherwise ("Extra Condensed").
const GRADES: [&str; 4] = ["extra", "ultra", "semi", "demi"];
let s = style.to_ascii_lowercase().replace('-', " ");
let words = s.split_whitespace().filter(|w| !WORDS.contains(w) && !w.chars().all(|c| c.is_ascii_digit()));
words.collect::<Vec<_>>().join(" ")
let words: Vec<&str> = s.split_whitespace().collect();
let weight = |w: &str| WEIGHTS.contains(&w) || w_number(w).is_some() || w.chars().all(|c| c.is_ascii_digit());
let rest = words.iter().enumerate().filter(|(i, w)| {
let graded = GRADES.contains(w) && words.get(i + 1).is_some_and(|next| weight(next) && !matches!(*next, "italic" | "oblique"));
!weight(w) && !graded
});
rest.map(|(_, w)| *w).collect::<Vec<_>>().join(" ")
}
/// A style name that slants: italic, or oblique as some families name it.
fn slanted(style: &str) -> bool {
let s = style.to_ascii_lowercase();
s.contains("italic") || s.contains("oblique")
}
/// The next heavier (`up`) or lighter style of `styles` (a family's) after the chars' style: the
/// nearest weight with the same slant and width, as the style menu lists them. None when the family
/// has nothing further that way.
/// The weight a style name says ("W3" too).
fn name_weight(style: &str) -> u16 {
w_number(&style.to_ascii_lowercase()).unwrap_or_else(|| figcraft_doc::style_weight(style))
}
/// The next heavier (`up`) or lighter style of `styles` (a family's, lightest first, as the style
/// menu lists them) after the chars' style, with the same slant and width. The chars' style is
/// found in the list by name and its neighbour taken; a style the family doesn't have, or a custom
/// weight axis value, goes to the nearest weight that way. None when the family has nothing further.
pub(crate) fn next_style(styles: &[String], st: &TextStyle, up: bool) -> Option<String> {
let (w, italic, rest) = (i32::from(st.weight()), st.italic(), style_rest(&st.font_style));
styles
.iter()
.filter(|x| slanted(x) == italic && style_rest(x) == rest)
.map(|x| (i32::from(figcraft_doc::style_weight(x)) - w, x))
let italic = st.italic() || slanted(&st.font_style);
let rest = style_rest(&st.font_style);
let mut same: Vec<&String> = styles.iter().filter(|x| slanted(x) == italic && style_rest(x) == rest).collect();
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
let here = same.iter().position(|x| norm(x) == norm(&st.font_style));
if let Some(i) = here.filter(|_| !st.variations.contains_key("wght")) {
let next = if up { i.checked_add(1) } else { i.checked_sub(1) };
return next.and_then(|n| same.get(n)).map(|x| (*x).clone());
}
let w = i32::from(st.weight());
// Of equal weights, the one nearest in the family's order: the first going up, the last going down.
if !up {
same.reverse();
}
same.into_iter()
.map(|x| (i32::from(name_weight(x)) - w, x))
.filter(|(d, _)| if up { *d > 0 } else { *d < 0 })
.min_by_key(|(d, _)| d.abs())
.map(|(_, x)| x.clone())
}
/// `text.weightUp` / `text.weightDown`: each targeted run moves to the next style of its family.
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
style(s, p, |st| {
let styles = figcraft_text::family_styles(&st.font_family);
+34 -6
View File
@@ -38,11 +38,39 @@ fn the_steps_follow_the_family_with_the_same_slant_and_width() {
assert_eq!(walk(&arial, "Narrow Bold Italic", false), ["Narrow Italic"]);
let segoe = styles(&["Light", "Semilight", "Regular", "Italic", "Semibold", "Bold", "Black", "Bold Italic"]);
assert_eq!(walk(&segoe, "Regular", true), ["Semibold", "Bold", "Black"]);
assert_eq!(walk(&segoe, "Regular", false), ["Light"]);
assert_eq!(walk(&segoe, "Regular", false), ["Semilight", "Light"]);
// A style the family doesn't have (as the old steps stored) moves to the next real one.
assert_eq!(next_style(&segoe, &at("Medium"), true).as_deref(), Some("Semibold"));
}
#[test]
fn extra_and_semi_before_a_width_keep_the_width() {
let family = styles(&["Extra Condensed Regular", "Condensed Regular", "Condensed Bold", "Extra Condensed Bold", "Semi Condensed Extra Bold"]);
assert_eq!(walk(&family, "Extra Condensed Regular", true), ["Extra Condensed Bold"]);
assert_eq!(walk(&family, "Condensed Bold", false), ["Condensed Regular"]);
assert_eq!(walk(&family, "Semi Condensed Extra Bold", false), Vec::<String>::new());
}
#[test]
fn oblique_names_step_to_oblique_faces() {
let dejavu = styles(&["Book", "Oblique", "Bold", "Bold Oblique"]);
assert_eq!(walk(&dejavu, "Oblique", true), ["Bold Oblique"]);
assert_eq!(walk(&dejavu, "Bold Oblique", false), ["Oblique"]);
assert_eq!(walk(&dejavu, "Book", true), ["Bold"]);
}
#[test]
fn hairline_and_w_numbers_are_weights() {
let family = styles(&["Hairline", "Thin", "Regular", "Bold"]);
assert_eq!(walk(&family, "Regular", false), ["Thin", "Hairline"]);
// Off the list (a stored style the family lacks): by weight, the nearer of two equal ones.
assert_eq!(next_style(&family, &at("Extra Light"), false).as_deref(), Some("Thin"));
assert_eq!(next_style(&family, &at("Extra Light"), true).as_deref(), Some("Regular"));
let hiragino = styles(&["W3", "W6"]);
assert_eq!(walk(&hiragino, "W3", true), ["W6"]);
assert_eq!(next_style(&hiragino, &at("Bold"), false).as_deref(), Some("W6"));
}
#[test]
fn a_custom_weight_axis_value_steps_from_where_it_is() {
let family = styles(&["Light", "Regular", "Medium", "Bold"]);
@@ -59,19 +87,19 @@ fn font_style(s: &Session, t: NodeId) -> String {
#[test]
fn the_commands_land_on_styles_the_family_has() {
figcraft_text::scan_system_fonts();
// Any installed family with a regular face and a heavier upright one (CI machines differ).
let upright = |x: &String| !x.to_lowercase().contains("italic") && !x.to_lowercase().contains("oblique");
// Any installed family with a Regular face and a style the step takes it to (CI machines differ).
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
let st = figcraft_text::family_styles(&f);
(st.iter().any(|x| x == "Regular") && st.iter().any(|x| upright(x) && figcraft_doc::style_weight(x) > 400)).then_some((f, st))
let up = next_style(&st, &at("Regular"), true).filter(|_| st.iter().any(|x| x == "Regular"))?;
Some((f, st, up))
});
let Some((family, styles)) = pick else { return };
let Some((family, styles, heavier)) = pick else { return };
let mut s = Session::new();
let r = s.execute("node.create", &json!({"type": "text", "text": "Hi", "props": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap();
let t = crate::params::id_of(&r["id"]).unwrap();
s.execute("text.weightUp", &json!({"ids": [t]})).unwrap();
let up = font_style(&s, t);
assert!(styles.contains(&up) && figcraft_doc::style_weight(&up) > 400, "{family}: {up} of {styles:?}");
assert_eq!(up, heavier, "{family}: {styles:?}");
s.execute("text.weightDown", &json!({"ids": [t]})).unwrap();
assert_eq!(font_style(&s, t), "Regular", "{family}: {styles:?}");
// A family that isn't installed still steps by 100 under the canonical names.