M4: weight steps from an off-list weight compare the faces' own weights (#1831)

next_style compared a custom wght value or a style the family lacks with
the weights the style names say, so Segoe UI Variable's Semilight (350)
counted as 300 and a wght of 325 stepped up to Regular. The family's
styles now come with the weights the style menu sorts them by
(figcraft_text::family_style_weights), and "W7 Italic" reads as 700.
This commit is contained in:
ljkx committed 2026-11-03 03:44:58 +01:00
1 parent dc82e6cac9
commit 2c9226c021
4 files changed
+69 -24

No files matched your search

+18 -15
View File
@@ -176,32 +176,35 @@ fn slanted(style: &str) -> bool {
s.contains("italic") || s.contains("oblique")
}
/// 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 weight a style name says ("W3" and "W6 Italic" too).
pub(crate) fn name_weight(style: &str) -> u16 {
let first = style.to_ascii_lowercase().replace('-', " ");
first.split_whitespace().next().and_then(w_number).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> {
/// The next heavier (`up`) or lighter style of `styles` (a family's with the weight each is listed
/// by, 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 listed style of nearest weight
/// that way. None when the family has nothing further.
pub(crate) fn next_style(styles: &[(String, u16)], st: &TextStyle, up: bool) -> Option<String> {
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 mut same: Vec<&(String, u16)> = 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 here = same.iter().position(|(x, _)| norm(x) == norm(&st.font_style));
let axis = st.variations.contains_key("wght");
if let Some(i) = here.filter(|_| !axis) {
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());
return next.and_then(|n| same.get(n)).map(|(x, _)| x.clone());
}
let w = i32::from(st.weight());
let w = i32::from(if axis { st.weight() } else { name_weight(&st.font_style) });
// 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))
.map(|(x, xw)| (i32::from(*xw) - w, x))
.filter(|(d, _)| if up { *d > 0 } else { *d < 0 })
.min_by_key(|(d, _)| d.abs())
.map(|(_, x)| x.clone())
@@ -210,7 +213,7 @@ pub(crate) fn next_style(styles: &[String], st: &TextStyle, up: bool) -> Option<
/// `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);
let styles = figcraft_text::family_style_weights(&st.font_family);
if styles.is_empty() {
// A family that isn't installed: step the weight by 100 under its canonical name.
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
+37 -6
View File
@@ -5,10 +5,16 @@ use figcraft_doc::{NodeId, TextStyle};
use serde_json::json;
use crate::Session;
use crate::cmd::text::next_style;
use crate::cmd::text::{name_weight, next_style};
fn styles(names: &[&str]) -> Vec<String> {
names.iter().map(|s| (*s).to_string()).collect()
/// A family's styles listed by the weights their names say, as for faces not read yet.
fn styles(names: &[&str]) -> Vec<(String, u16)> {
names.iter().map(|s| ((*s).to_string(), name_weight(s))).collect()
}
/// A family's styles with the faces' own weights.
fn weighed(faces: &[(&str, u16)]) -> Vec<(String, u16)> {
faces.iter().map(|(s, w)| ((*s).to_string(), *w)).collect()
}
fn at(style: &str) -> TextStyle {
@@ -18,7 +24,7 @@ fn at(style: &str) -> TextStyle {
}
/// Walk `from` heavier (`up`) or lighter until the family has nothing further.
fn walk(family: &[String], from: &str, up: bool) -> Vec<String> {
fn walk(family: &[(String, u16)], from: &str, up: bool) -> Vec<String> {
let mut st = at(from);
let mut out = Vec::new();
while let Some(next) = next_style(family, &st, up) {
@@ -80,6 +86,31 @@ fn a_custom_weight_axis_value_steps_from_where_it_is() {
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Medium"));
}
#[test]
fn off_the_list_the_faces_own_weights_decide() {
// From 500 between 400 and 700 faces: the neighbours, under their own names (the axis value goes).
let family = weighed(&[("Light", 300), ("Regular", 400), ("Bold", 700)]);
let mut st = at("Regular");
st.variations.insert("wght".into(), 500.0);
assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold"));
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Regular"));
st.set_font_style("Bold");
assert_eq!((st.weight(), st.variations.get("wght")), (700, None));
// Segoe UI Variable's Semilight is 350, not the 300 its name says: from a `wght` of 325 it is
// the next heavier style, and from 375 the next lighter.
let segoe = weighed(&[("Light", 300), ("Semilight", 350), ("Regular", 400), ("Semibold", 600), ("Bold", 700)]);
st.variations.insert("wght".into(), 325.0);
assert_eq!(next_style(&segoe, &st, true).as_deref(), Some("Semilight"));
assert_eq!(next_style(&segoe, &st, false).as_deref(), Some("Light"));
st.variations.insert("wght".into(), 375.0);
assert_eq!(next_style(&segoe, &st, false).as_deref(), Some("Semilight"));
assert_eq!(next_style(&segoe, &st, true).as_deref(), Some("Regular"));
// A W-numbered name with a slant after it is still that weight ("W7 Italic" is 700).
let hiragino = weighed(&[("W3 Italic", 300), ("W6 Italic", 600), ("W8 Italic", 800)]);
assert_eq!(next_style(&hiragino, &at("W7 Italic"), false).as_deref(), Some("W6 Italic"));
assert_eq!(next_style(&hiragino, &at("W7 Italic"), true).as_deref(), Some("W8 Italic"));
}
fn font_style(s: &Session, t: NodeId) -> String {
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style.clone()
}
@@ -89,8 +120,8 @@ fn the_commands_land_on_styles_the_family_has() {
figcraft_text::scan_system_fonts();
// 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);
let up = next_style(&st, &at("Regular"), true).filter(|_| st.iter().any(|x| x == "Regular"))?;
let st = figcraft_text::family_style_weights(&f);
let up = next_style(&st, &at("Regular"), true).filter(|_| st.iter().any(|(x, _)| x == "Regular"))?;
Some((f, st, up))
});
let Some((family, styles, heavier)) = pick else { return };
+12 -1
View File
@@ -743,6 +743,12 @@ impl FontDb {
/// Style names of a family, by weight then italic.
pub fn styles(&mut self, family: &str) -> Vec<String> {
self.style_weights(family).into_iter().map(|(name, _)| name).collect()
}
/// [`styles`](Self::styles) with the weight each is listed by: the face's own (Segoe UI
/// Semilight is 350), a named instance's `wght`, or for a face not read yet its name's.
pub fn style_weights(&mut self, family: &str) -> Vec<(String, u16)> {
self.load_family(family, None);
let key = self.key(family);
// (weight, italic, order in the font, name): named instances keep the font's order.
@@ -768,7 +774,7 @@ impl FontDb {
v.sort();
let mut seen = std::collections::HashSet::new();
v.retain(|x| seen.insert(norm_style(&x.3)));
v.into_iter().map(|x| x.3).collect()
v.into_iter().map(|x| (x.3, x.0)).collect()
}
/// The key `family` is known under: itself in any case; for a name designs use for Apple's
@@ -1502,6 +1508,11 @@ pub fn family_styles(family: &str) -> Vec<String> {
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family)
}
/// [`family_styles`] with the weight each is listed by (see [`FontDb::style_weights`]).
pub fn family_style_weights(family: &str) -> Vec<(String, u16)> {
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).style_weights(family)
}
/// Scan system fonts into the global db (desktop; a no-op on the web, or with
/// `FIGCRAFT_NO_SYSTEM_FONTS` set). Runs once per process: a concurrent caller waits for that scan.
/// The directory walk runs without holding the database lock; results are merged in one short write.
+2 -2
View File
@@ -17,8 +17,8 @@ pub use breaks::{backspace_step, grapheme_step};
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, LocalFont, NamedInstance, Rescan, add_local_fonts, axis_label, db, face_with_char, family_names,
family_styles, find_face, has_family, installed_fonts_known, load_font_bytes, load_local_font, local_only, no_coords, opt_out_of_system_fonts,
rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, take_wanted_local_fonts,
family_style_weights, family_styles, find_face, has_family, installed_fonts_known, load_font_bytes, load_local_font, local_only, no_coords,
opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, take_wanted_local_fonts,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;