From 30550f038ad673c3c8089619886b99db27994e66 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 23:30:32 +0700 Subject: [PATCH] Fix #136: SVG export and CSS write each style's numeric font-weight font_props wrote `bold` for any style whose name contained bold, black or heavy and nothing for the others unless fonts were embedded, so Semibold came back Bold and Light or Medium came back Regular. It now writes the style's numeric weight (font_descriptor, from style_weight) for every writer: 600 for Semibold, 300 for Light, 900 for Black, `bold` for 700 and nothing for 400. The SVG import already reads these numbers back to the matching face. Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/css.rs | 19 ++++---- crates/svg/src/export.rs | 6 +-- crates/svg/tests/font_weights.rs | 78 ++++++++++++++++++++++++++++++++ docs/mcp.md | 3 +- 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 crates/svg/tests/font_weights.rs diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index df45d5b8..37e2772a 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -72,21 +72,18 @@ pub(crate) fn font_descriptor(st: &CharStyle) -> (u16, bool) { } /// The font of character style `st`: family, size (the vertical scale; writers stretch the -/// horizontal one), weight and style, lengths written by `len`. `numeric_weight`: the face's -/// weight as a number (embedded faces are told apart by it), else `bold` for bold styles. -pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String, numeric_weight: bool) -> Props { +/// horizontal one), weight and style, lengths written by `len`. The weight is the style's number +/// (600 for Semibold, 300 for Light), `bold` for 700 and left out for 400. +pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { let mut p = Props::new(); let fam = if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() }; p.push(("font-family", fam)); p.push(("font-size", len(st.size * st.v_scale / 100.0))); - let fs = st.font_style.to_ascii_lowercase(); let (weight, italic) = font_descriptor(st); - if numeric_weight { - if weight != 400 { - p.push(("font-weight", weight.to_string())); - } - } else if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") { - p.push(("font-weight", "bold".into())); + match weight { + 400 => {} + 700 => p.push(("font-weight", "bold".into())), + w => p.push(("font-weight", w.to_string())), } if italic { p.push(("font-style", "italic".into())); @@ -531,7 +528,7 @@ impl Rules<'_> { self.warn("type with several character styles is written in its first one"); } let len = |v: f64| self.len(v); - art.props.extend(font_props(&st, &len, false)); + art.props.extend(font_props(&st, &len)); match &st.fill { Paint::Solid { color, .. } => art.props.push(("color", color.to_hex())), Paint::None => art.props.push(("color", "transparent".into())), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 663992a9..44c8c498 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -411,10 +411,6 @@ impl Writer<'_> { fn tiny(&self) -> bool { self.opts.profile == Profile::Tiny12 } - /// Are the fonts type uses embedded? - fn embeds_fonts(&self) -> bool { - self.opts.embed_fonts && !self.opts.outline_text && !self.tiny() - } fn num(&self, v: f64) -> String { fmt_num(v, self.opts.decimals) } @@ -1505,7 +1501,7 @@ impl Writer<'_> { fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { let decimals = self.opts.decimals; let len = |v: f64| fmt_num(v, decimals); - let mut p = css::font_props(st, &len, self.embeds_fonts()); + let mut p = css::font_props(st, &len); // Character paints resolve in the text's space (`to_user` maps it into user space). let (bounds, to_user) = space; let saved = std::mem::replace(&mut self.xf, to_user); diff --git a/crates/svg/tests/font_weights.rs b/crates/svg/tests/font_weights.rs new file mode 100644 index 00000000..f642930c --- /dev/null +++ b/crates/svg/tests/font_weights.rs @@ -0,0 +1,78 @@ +//! Font weights in SVG and CSS: every style is written with its numeric weight (Semibold 600, +//! Light 300, Black 900…; Bold as `bold`), and SVG import reads the numbers back to the same face. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; +use vectorcraft_geom::Point; +use vectorcraft_svg::{CssOptions, ExportOptions, css_rules, export, import}; +use vectorcraft_text::FontDb; + +/// A document with one text in `family` `style`. +fn doc(family: &str, style: &str) -> (Document, vectorcraft_doc::NodeId) { + let mut d = Document::new(300.0, 100.0); + let st = CharStyle { font_family: family.into(), font_style: style.into(), size: 24.0, ..CharStyle::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), "Weight", st)))); + let (l, id) = (d.layers[0].id, n.id); + d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +/// The `font-weight` the SVG export and the CSS of the text write. +fn weights(style: &str) -> (Option, Option) { + let (d, id) = doc("Source Sans 3", style); + let svg = export(&d, &ExportOptions::default()); + let attr = svg.split("font-weight=\"").nth(1).map(|s| s.split('"').next().unwrap().to_string()); + let sheet = css_rules(&d, &[id], &CssOptions::default()); + let css = sheet.rules[0].props.iter().find(|(k, _)| *k == "font-weight").map(|(_, v)| v.clone()); + (attr, css) +} + +fn style_back(family: &str, style: &str) -> String { + let svg = export(&doc(family, style).0, &ExportOptions::default()); + let mut found = None; + import(&svg).unwrap().walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found = Some(t.runs[0].style.font_style.clone()); + } + }); + found.expect("the text comes back") +} + +#[test] +fn every_style_is_written_with_its_numeric_weight() { + for (style, want) in [ + ("Thin", Some("100")), + ("ExtraLight", Some("200")), + ("Light", Some("300")), + ("Regular", None), + ("Italic", None), + ("Medium", Some("500")), + ("Semibold", Some("600")), + ("Demibold", Some("600")), + ("Bold", Some("bold")), + ("Bold Italic", Some("bold")), + ("Extrabold", Some("800")), + ("Black", Some("900")), + ("Heavy", Some("900")), + ] { + let want = want.map(str::to_string); + assert_eq!(weights(style), (want.clone(), want), "{style}"); + } +} + +#[test] +fn numeric_weights_read_back_to_the_style() { + let db = FontDb::global(); + for (family, style) in + [("Source Sans 3", "Semibold"), ("Source Sans 3", "Bold"), ("Source Sans 3", "Italic"), ("Inter", "Medium"), ("Inter", "SemiBold")] + { + let back = style_back(family, style); + let face = |s: &str| db.face(family, s).map(|f| f.style.clone()); + assert_eq!(face(&back), face(style), "{family} {style} came back as {back}"); + } + // Styles the family doesn't have keep their weight. + assert_eq!(style_back("Source Sans 3", "Light"), "Light"); + assert_eq!(style_back("Source Sans 3", "Black Italic"), "Black Italic"); + assert_eq!(style_back("Source Sans 3", "Heavy"), "Black"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 89040d50..61d16623 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -133,7 +133,8 @@ lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian a (presentation attributes only; filters, masks, blend modes and embedded fonts left out, symbols as art, each with a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out -with a warning, and the type then names faces by numeric `font-weight` (600 for Semibold). `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, +with a warning. Type names its faces by numeric `font-weight` (600 for Semibold, 300 for Light, `bold` for 700), +embedded or not. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`). Symbols export as one `` with a `` per instance. An instance the def can't stand for is written as its