mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
M4: Increase and decrease font weight step through the styles the family has (#1831)
text.weightUp/weightDown stepped the CSS weight by 100 and stored a canonical
name ("Medium", "Semi Bold", ...) whether the family had that face or not, so
the first press on Arial Regular changed nothing visible and the stored style
named a face no font provides. They now move to the nearest heavier or lighter
style of the family with the same slant and width (Narrow stays Narrow), and
stay put at either end. A family that isn't installed keeps the old 100 steps.
This commit is contained in:
1 parent
e65ce42000
commit
6365e4339b
3 files changed
+145
-3
No files matched your search
@@ -122,11 +122,70 @@ fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What a style name says besides its weight and slant ("Narrow Bold Italic" → "narrow").
|
||||
fn style_rest(style: &str) -> String {
|
||||
const WORDS: [&str; 25] = [
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"regular",
|
||||
"normal",
|
||||
"roman",
|
||||
"book",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"black",
|
||||
"heavy",
|
||||
"semi",
|
||||
"demi",
|
||||
"extra",
|
||||
"ultra",
|
||||
"italic",
|
||||
"oblique",
|
||||
"plain",
|
||||
"standard",
|
||||
];
|
||||
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(" ")
|
||||
}
|
||||
|
||||
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.
|
||||
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))
|
||||
.filter(|(d, _)| if up { *d > 0 } else { *d < 0 })
|
||||
.min_by_key(|(d, _)| d.abs())
|
||||
.map(|(_, x)| x.clone())
|
||||
}
|
||||
|
||||
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
style(s, p, |st| {
|
||||
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
|
||||
let italic = st.italic();
|
||||
st.set_font_style(styled_name(weight_name(w), italic));
|
||||
let styles = figcraft_text::family_styles(&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;
|
||||
let italic = st.italic();
|
||||
st.set_font_style(styled_name(weight_name(w), italic));
|
||||
} else if let Some(next) = next_style(&styles, st, up) {
|
||||
st.set_font_style(next);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,8 @@ mod text_style_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod text_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod text_weight_step_tests;
|
||||
#[cfg(test)]
|
||||
mod textfit_tests;
|
||||
#[cfg(test)]
|
||||
mod theme_pages_tests;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Increase and decrease font weight step through the styles the family has, by weight with the
|
||||
//! same slant and width (#1831), not by 100 to a canonical name no face provides.
|
||||
|
||||
use figcraft_doc::{NodeId, TextStyle};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::text::next_style;
|
||||
|
||||
fn styles(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
fn at(style: &str) -> TextStyle {
|
||||
let mut st = TextStyle::default();
|
||||
st.set_font_style(style);
|
||||
st
|
||||
}
|
||||
|
||||
/// Walk `from` heavier (`up`) or lighter until the family has nothing further.
|
||||
fn walk(family: &[String], 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) {
|
||||
st.set_font_style(next.clone());
|
||||
out.push(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_steps_follow_the_family_with_the_same_slant_and_width() {
|
||||
let arial = styles(&["Narrow", "Regular", "Italic", "Narrow Italic", "Bold", "Narrow Bold", "Bold Italic", "Narrow Bold Italic", "Black"]);
|
||||
assert_eq!(walk(&arial, "Regular", true), ["Bold", "Black"]);
|
||||
assert_eq!(walk(&arial, "Black", false), ["Bold", "Regular"]);
|
||||
assert_eq!(walk(&arial, "Italic", true), ["Bold Italic"]);
|
||||
assert_eq!(walk(&arial, "Narrow", true), ["Narrow Bold"]);
|
||||
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"]);
|
||||
// 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 a_custom_weight_axis_value_steps_from_where_it_is() {
|
||||
let family = styles(&["Light", "Regular", "Medium", "Bold"]);
|
||||
let mut st = at("Regular");
|
||||
st.variations.insert("wght".into(), 550.0);
|
||||
assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold"));
|
||||
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Medium"));
|
||||
}
|
||||
|
||||
fn font_style(s: &Session, t: NodeId) -> String {
|
||||
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style.clone()
|
||||
}
|
||||
|
||||
#[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");
|
||||
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 Some((family, styles)) = 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:?}");
|
||||
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.
|
||||
s.execute("node.set", &json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Regular"}})).unwrap();
|
||||
s.execute("text.weightUp", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(font_style(&s, t), "Medium");
|
||||
}
|
||||
Reference in new issue
Block a user