From 0f855be77fab2ec2a270f6a1e95f42b2d326cdc4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:29:40 +0200 Subject: [PATCH] Add a generic family to the font-family of SVG export and CSS (#3135) --- crates/engine/src/tests_css.rs | 2 +- crates/svg/src/css.rs | 18 +++++++++++++++++- crates/svg/tests/css_rules.rs | 23 +++++++++++++++++++++-- crates/svg/tests/text_layout.rs | 2 +- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests_css.rs b/crates/engine/src/tests_css.rs index 24976e1e6..8a39eb059 100644 --- a/crates/engine/src/tests_css.rs +++ b/crates/engine/src/tests_css.rs @@ -48,7 +48,7 @@ fn type_gives_font_properties_and_gradients_css_gradients() { let mut s = session(); let t = run(&mut s, "text.create", json!({"x": 20, "y": 50, "text": "Title", "size": 24}))["id"].as_u64().unwrap(); let text = css(&run(&mut s, "css.selection", json!({"ids": [t]}))).to_string(); - for decl in ["font-family: 'Source Sans 3';", "font-size: 24px;", "color: #"] { + for decl in ["font-family: 'Source Sans 3', sans-serif;", "font-size: 24px;", "color: #"] { assert!(text.contains(decl), "{decl} in {text}"); } run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50})); diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index c0876400d..e9b4ef67a 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -85,6 +85,22 @@ pub(crate) fn font_stretch(st: &CharStyle) -> Option { st.font_axes.get("wdth").copied().filter(|w| w.is_finite() && *w > 0.0) } +/// The generic family to list after `family`, with its leading comma, so a viewer without the font draws +/// the kind of face it was (the font's class, as the font menu's filters know it) rather than its default +/// serif. Nothing when `family` is a generic family itself. +fn generic_fallback(family: &str) -> &'static str { + const GENERIC: [&str; 5] = ["serif", "sans-serif", "monospace", "cursive", "fantasy"]; + if GENERIC.iter().any(|g| family.trim().eq_ignore_ascii_case(g)) { + return ""; + } + match vectorcraft_text::FontDb::global().family_traits(family).class { + vectorcraft_text::FontClass::Serif => ", serif", + vectorcraft_text::FontClass::Monospaced => ", monospace", + vectorcraft_text::FontClass::Script => ", cursive", + _ => ", sans-serif", + } +} + /// The font of character style `st`: family, size (the vertical scale; writers stretch the /// horizontal one), weight and style, and its Variable Font settings, lengths written by `len`. /// The weight is the style's number (600 for Semibold, 300 for Light) or the `wght` setting, @@ -98,7 +114,7 @@ pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { } else { st.font_family.clone() }; - p.push(("font-family", fam)); + p.push(("font-family", format!("{fam}{}", generic_fallback(&st.font_family)))); p.push(("font-size", len(st.size * st.v_scale / 100.0))); let (weight, italic) = font_descriptor(st); match weight { diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 036f1bd91..309425e4e 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -163,7 +163,7 @@ fn type_gives_font_properties() { let id = add(&mut d, n); let r = one(&d, id, &CssOptions::default()); assert_eq!(r.selector, ".type"); - assert_eq!(prop(&r, "font-family"), Some("'Source Sans 3'")); + assert_eq!(prop(&r, "font-family"), Some("'Source Sans 3', sans-serif")); assert_eq!(prop(&r, "font-size"), Some("18px")); assert_eq!(prop(&r, "font-weight"), Some("bold")); assert_eq!(prop(&r, "font-style"), Some("italic")); @@ -281,9 +281,28 @@ fn groups_stand_for_their_objects_and_hidden_ones_are_left_out() { assert_eq!(sheet.text(), sheet.rules[0].text()); } +#[test] +fn font_family_ends_with_a_generic_family_for_viewers_without_the_font() { + let family = |name: &str| { + let mut d = doc(); + let st = CharStyle { font_family: name.into(), size: 18.0, ..CharStyle::default() }; + let n = text(&mut d, "Hello", st); + let id = add(&mut d, n); + prop(&one(&d, id, &CssOptions::default()), "font-family").map(str::to_string) + }; + // Source Sans 3 is a sans face; an unknown family gets the same default; a generic family stands alone. + assert_eq!(family("Source Sans 3").as_deref(), Some("'Source Sans 3', sans-serif")); + assert_eq!(family("No Such Font").as_deref(), Some("'No Such Font', sans-serif")); + assert_eq!(family("serif").as_deref(), Some("serif")); +} + #[test] fn font_family_with_quote_or_backslash_is_escaped() { - for (family, want) in [("Bob's Font", r"'Bob\'s Font'"), (r"Back\slash Font", r"'Back\\slash Font'"), ("Bob's", r"'Bob\'s'")] { + for (family, want) in [ + ("Bob's Font", r"'Bob\'s Font', sans-serif"), + (r"Back\slash Font", r"'Back\\slash Font', sans-serif"), + ("Bob's", r"'Bob\'s', sans-serif"), + ] { let mut d = doc(); let st = CharStyle { font_family: family.into(), size: 18.0, ..CharStyle::default() }; let n = text(&mut d, "Hello", st); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index e99bc822e..649aadf47 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -300,7 +300,7 @@ fn lines_without_moved_characters_are_written_as_before() { } let open = |x: &str, anchor: &str| { format!( - r##""## + r##""## ) }; let words =