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