Merge pull request #148 from storytold/fix/issue-136

Fix #136: SVG export and CSS write each style's numeric font-weight
This commit is contained in:
BFlat 2026-10-05 23:48:42 +07:00 committed by GitHub
commit e94dfed775
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 17 deletions

View File

@ -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())),

View File

@ -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);

View File

@ -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<String>, Option<String>) {
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");
}

View File

@ -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 `<symbol>` with a `<use>` per instance. An instance the def can't stand for is written as its