mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
The font database made one style per font file, so a variable font such as Figtree[wght].ttf listed only its default instance (Light). Faces now also yield their fvar named instances, each a style of the family named by its subfamily name (the default instance is the face itself; unnamed or duplicate instances are left out; axes and instances read are capped). The system font scan reads the fvar table beside the name table, so installed instances are listed before the file is loaded and found by name and by PostScript name. An instance face carries its axis settings: skrifa draws its outlines and reads its metrics and advances at that location, harfrust shapes with a ShaperInstance, so layout, the canvas, Create Outlines and hit testing all use the instance. PDF export embeds it through krilla's Font::new_variable, SVG @font-face through subsetter's subset_with_variations: both write a static font of that instance. Tests build a small variable font in code (test_fonts, via write-fonts; no font file is committed) and check listing, resolving, glyphs and advances, PDF and SVG export and damaged fvar/gvar tables; Bahnschrift and Segoe UI Variable are checked where installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
15 KiB
Rust
299 lines
15 KiB
Rust
//! SVG Options → Encoding, SVG Profile and embedded fonts: UTF-16 and ISO 8859-1 files read
|
||
//! back, entity styling parses in usvg, SVG Tiny 1.2 has no filters, masks, symbols or style
|
||
//! sheets, and embedded fonts hold only the glyphs used where their licence allows it.
|
||
// 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 std::sync::Arc;
|
||
|
||
use serde_json::json;
|
||
use skrifa::raw::TableProvider;
|
||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Node, NodeKind, OpacityMask, Symbol, TextObject, TextRun};
|
||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||
use vectorcraft_svg::{Encoding, ExportOptions, Profile, Styling, compress_bytes, editing, export_full, import, text_of};
|
||
use vectorcraft_testkit::format::base64_decode;
|
||
|
||
fn add(d: &mut Document, mut n: Node) {
|
||
n.id = d.alloc_id();
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||
}
|
||
|
||
fn style(family: &str, font_style: &str) -> CharStyle {
|
||
CharStyle { size: 24.0, font_family: family.into(), font_style: font_style.into(), fill: Paint::solid(Color::BLACK), ..CharStyle::default() }
|
||
}
|
||
|
||
fn text(at: Point, runs: &[(&str, CharStyle)]) -> Node {
|
||
let mut t = TextObject::point(at, "", runs[0].1.clone());
|
||
t.runs = runs.iter().map(|(s, st)| TextRun { text: (*s).into(), style: st.clone() }).collect();
|
||
Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t)))
|
||
}
|
||
|
||
/// The line of type [`doc`] holds: Latin-1 characters and others the bundled fonts have.
|
||
const LINE: &str = "Grüße — 5 €";
|
||
|
||
/// A red square with a Chinese name, a Chinese title and a line of type ([`LINE`]).
|
||
fn doc() -> Document {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
d.title = "Grüße 中文".into();
|
||
let red = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0);
|
||
let mut sq = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), red);
|
||
sq.name = Some("方块".into());
|
||
add(&mut d, sq);
|
||
add(&mut d, text(Point::new(20.0, 120.0), &[(LINE, style("Source Sans 3", "Regular"))]));
|
||
d
|
||
}
|
||
|
||
fn plain(d: &Document) -> String {
|
||
let mut out = String::new();
|
||
d.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
out.push_str(&t.plain_text());
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
fn red_fill(d: &Document) -> bool {
|
||
let mut red = false;
|
||
d.walk(|n| red |= n.appearance.fill_paint() == Paint::solid(Color::rgb8(255, 0, 0)));
|
||
red
|
||
}
|
||
|
||
#[test]
|
||
fn utf16_files_start_with_a_byte_order_mark_and_read_back() {
|
||
let o = export_full(&doc(), &ExportOptions { encoding: Encoding::Utf16, ..Default::default() }, None);
|
||
let bytes = o.bytes();
|
||
assert_eq!(&bytes[..2], &[0xfe, 0xff], "big-endian byte order mark");
|
||
assert_eq!(bytes.len(), 2 + 2 * o.svg.encode_utf16().count());
|
||
let svg = text_of(&bytes).unwrap();
|
||
assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-16\"?>"), "{svg}");
|
||
assert!(svg.contains(LINE) && svg.contains("<title>Grüße 中文</title>"), "{svg}");
|
||
let back = import(&svg).unwrap();
|
||
assert_eq!(plain(&back), LINE);
|
||
// Gzipped (SVGZ) too, and little-endian files from elsewhere.
|
||
assert_eq!(text_of(&compress_bytes(&bytes)).unwrap(), svg);
|
||
let le: Vec<u8> = [0xff, 0xfe].into_iter().chain(svg.encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||
assert_eq!(text_of(&le).unwrap(), svg);
|
||
assert!(text_of(&[0xfe, 0xff, 0xd8]).is_err(), "odd UTF-16 is refused");
|
||
}
|
||
|
||
#[test]
|
||
fn latin1_files_write_other_characters_as_references() {
|
||
let d = doc();
|
||
for (minify, styling) in [(false, Styling::PresentationAttributes), (true, Styling::StyleEntities)] {
|
||
let o = export_full(
|
||
&d,
|
||
&ExportOptions { encoding: Encoding::Latin1, minify, styling, preserve_editing: true, ..Default::default() },
|
||
Some(b"native"),
|
||
);
|
||
let bytes = o.bytes();
|
||
// The declaration names the encoding even minified; ü is one byte, € and 中 references.
|
||
assert!(bytes.starts_with(b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>"), "{}", o.svg);
|
||
assert!(bytes.windows(4).any(|w| w == b"Gr\xfc\xdf"), "{}", o.svg);
|
||
assert!(o.svg.contains("— 5 €") && o.svg.contains("中文"), "{}", o.svg);
|
||
assert!(o.svg.chars().all(|c| u32::from(c) < 0x100), "{}", o.svg);
|
||
let svg = text_of(&bytes).unwrap();
|
||
assert_eq!(svg, o.svg);
|
||
let back = import(&svg).unwrap();
|
||
assert_eq!(plain(&back), LINE, "{minify}");
|
||
assert!(red_fill(&back));
|
||
// usvg reads the entities (and their references) too.
|
||
let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap();
|
||
assert!(tree.root().has_children());
|
||
// The editing data's hash covers the file as written.
|
||
assert!(editing(&svg).unwrap().intact, "{minify}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn style_entities_parse_in_usvg() {
|
||
let o = export_full(&doc(), &ExportOptions { styling: Styling::StyleEntities, ..Default::default() }, None);
|
||
assert!(o.svg.contains("<!ENTITY st1 ") && o.svg.contains("style=\"&st1;\""), "{}", o.svg);
|
||
let tree = usvg::Tree::from_str(&o.svg, &usvg::Options::default()).unwrap();
|
||
let mut red = false;
|
||
fn walk(g: &usvg::Group, red: &mut bool) {
|
||
for n in g.children() {
|
||
match n {
|
||
usvg::Node::Path(p) => {
|
||
*red |= matches!(p.fill().map(|f| f.paint()), Some(usvg::Paint::Color(c)) if (c.red, c.green, c.blue) == (255, 0, 0))
|
||
}
|
||
usvg::Node::Group(g) => walk(g, red),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
walk(tree.root(), &mut red);
|
||
assert!(red, "the entity's fill reached usvg: {}", o.svg);
|
||
}
|
||
|
||
#[test]
|
||
fn tiny_has_no_filters_masks_symbols_or_style_sheets() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let fill = |r, g, b| Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0);
|
||
// A shadowed square, a masked one in Multiply, a symbol instance and an outside stroke.
|
||
let mut a = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), fill(255, 0, 0));
|
||
a.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true });
|
||
add(&mut d, a);
|
||
let mut b = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(0, 0, 255));
|
||
b.blend = BlendMode::Multiply;
|
||
b.mask =
|
||
Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), shapes::ellipse(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(255, 255, 255)), true)));
|
||
add(&mut d, b);
|
||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), fill(0, 128, 0));
|
||
d.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
|
||
add(&mut d, Node::new(vectorcraft_doc::NodeId(0), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((200.0, 30.0)) }));
|
||
let mut c = Node::path(
|
||
vectorcraft_doc::NodeId(0),
|
||
shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 150.0)),
|
||
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0),
|
||
);
|
||
if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = c.appearance.items.get_mut(1) {
|
||
s.align = vectorcraft_doc::StrokeAlign::Outside;
|
||
}
|
||
add(&mut d, c);
|
||
let full = export_full(&d, &ExportOptions { styling: Styling::InternalCss, ..Default::default() }, None).svg;
|
||
assert!(full.contains("<filter") && full.contains("<mask") && full.contains("<symbol") && full.contains("<style>"), "{full}");
|
||
let o =
|
||
export_full(&d, &ExportOptions { styling: Styling::InternalCss, profile: Profile::Tiny12, embed_fonts: true, ..Default::default() }, None);
|
||
let svg = &o.svg;
|
||
assert!(svg.contains("version=\"1.2\" baseProfile=\"tiny\""), "{svg}");
|
||
for gone in ["<filter", "<mask", "<symbol", "<style", "style=", "class=", "mix-blend-mode"] {
|
||
assert!(!svg.contains(gone), "{gone}: {svg}");
|
||
}
|
||
assert!(svg.contains("clip-rule=\"evenodd\""), "the outside stroke is clipped outside the shape: {svg}");
|
||
for w in ["filters", "masks", "style sheets"] {
|
||
assert!(o.warnings.iter().any(|x| x.contains(w)), "{w}: {:?}", o.warnings);
|
||
}
|
||
let back = import(svg).unwrap();
|
||
assert!(red_fill(&back));
|
||
assert!(back.symbols.is_empty(), "the instance came back as art");
|
||
}
|
||
|
||
/// The fonts a file embeds: (font-family, weight, the font file).
|
||
fn embedded(svg: &str) -> Vec<(String, u16, Vec<u8>)> {
|
||
let css = svg.replace(""", "\"");
|
||
css.split("@font-face{")
|
||
.skip(1)
|
||
.map(|r| {
|
||
let field = |k: &str| r.split(&format!("{k}:")).nth(1).unwrap().split(';').next().unwrap().to_string();
|
||
let data = r.split("base64,").nth(1).unwrap().split(')').next().unwrap();
|
||
(field("font-family"), field("font-weight").parse().unwrap(), base64_decode(data).unwrap())
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_fonts_hold_only_the_glyphs_used() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
add(&mut d, text(Point::new(20.0, 60.0), &[("Hello ", style("Source Sans 3", "Regular")), ("Bold", style("Source Sans 3", "Semibold"))]));
|
||
let svg = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None).svg;
|
||
let fonts = embedded(&svg);
|
||
assert_eq!(fonts.iter().map(|f| (f.0.as_str(), f.1)).collect::<Vec<_>>(), [("\"Source Sans 3\"", 400), ("\"Source Sans 3\"", 600)], "{svg}");
|
||
assert!(svg.contains("font-weight=\"600\"") && svg.contains("format("truetype")"), "{svg}");
|
||
for (_, w, data) in &fonts {
|
||
let f = skrifa::FontRef::new(data).unwrap();
|
||
let cmap = f.cmap().unwrap();
|
||
let want: &[char] = if *w == 400 { &['H', 'e', 'l', 'o', ' '] } else { &['B', 'o', 'l', 'd'] };
|
||
assert!(want.iter().all(|c| cmap.map_codepoint(*c).is_some()), "{w}");
|
||
assert!(cmap.map_codepoint('x').is_none(), "{w}: unused characters are left out");
|
||
// .notdef and the characters' own glyphs.
|
||
let unique = want.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||
assert_eq!(f.maxp().unwrap().num_glyphs() as usize, unique + 1, "{w}");
|
||
}
|
||
// The weights read back as the styles.
|
||
let back = import(&svg).unwrap();
|
||
let mut styles = vec![];
|
||
back.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
styles.extend(t.runs.iter().map(|r| r.style.font_style.to_ascii_lowercase()));
|
||
}
|
||
});
|
||
assert_eq!(styles, ["regular", "semibold"]);
|
||
// Without the option, or with outlined text, nothing is embedded.
|
||
assert!(!export_full(&d, &ExportOptions::default(), None).svg.contains("@font-face"));
|
||
assert!(!export_full(&d, &ExportOptions { embed_fonts: true, outline_text: true, ..Default::default() }, None).svg.contains("@font-face"));
|
||
}
|
||
|
||
/// A named instance of a variable font embeds as a static font of that instance: the variable
|
||
/// file would show its default instance in browsers (#296).
|
||
#[test]
|
||
fn variable_font_instances_embed_as_themselves() {
|
||
use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, variable_font};
|
||
let db = vectorcraft_text::FontDb::global();
|
||
db.add_font(variable_font().unwrap());
|
||
let mut d = Document::new(300.0, 200.0);
|
||
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(VARIABLE_FAMILY, "Regular")), ("ll", style(VARIABLE_FAMILY, "Bold"))]));
|
||
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
|
||
let fonts = embedded(&o.svg);
|
||
assert_eq!(fonts.iter().map(|f| f.1).collect::<Vec<_>>(), [400, 700], "{:?}", o.warnings);
|
||
let mut advances = vec![];
|
||
for ((_, w, data), style) in fonts.iter().zip(["Regular", "Bold"]) {
|
||
let f = skrifa::FontRef::new(data).unwrap();
|
||
assert!(f.fvar().is_err() && f.gvar().is_err(), "{w}: a static font");
|
||
let gid = f.cmap().unwrap().map_codepoint('l').unwrap();
|
||
let adv = f.hmtx().unwrap().advance(gid).unwrap();
|
||
let face = db.face(VARIABLE_FAMILY, style).unwrap();
|
||
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round(), "{style}");
|
||
advances.push(adv);
|
||
}
|
||
assert!(advances[1] > advances[0], "{advances:?}");
|
||
}
|
||
|
||
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
|
||
/// name) with OS/2 `fsType` set to `fs_type`.
|
||
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
|
||
let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec();
|
||
let table = |data: &[u8], tag: &[u8; 4]| {
|
||
let f = skrifa::FontRef::new(data).unwrap();
|
||
let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap();
|
||
(r.offset() as usize, r.length() as usize)
|
||
};
|
||
let (os2, _) = table(&data, b"OS/2");
|
||
data[os2 + 8..os2 + 10].copy_from_slice(&fs_type.to_be_bytes());
|
||
let (name, len) = table(&data, b"name");
|
||
assert_eq!(family.len(), "Source Sans 3".len());
|
||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||
let mut i = name;
|
||
while let Some(at) = data[i..name + len].windows(from.len()).position(|w| w == from) {
|
||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||
i += at + to.len();
|
||
}
|
||
}
|
||
data
|
||
}
|
||
|
||
#[test]
|
||
fn font_licences_are_respected() {
|
||
let db = vectorcraft_text::FontDb::global();
|
||
db.add_font(licensed_font("Sealed Sans 3", 0x0002));
|
||
db.add_font(licensed_font("Wholed Sans 3", 0x0100));
|
||
assert!(db.has_family("Sealed Sans 3") && db.has_family("Wholed Sans 3"));
|
||
let mut d = Document::new(300.0, 200.0);
|
||
add(&mut d, text(Point::new(20.0, 60.0), &[("Restricted ", style("Sealed Sans 3", "Regular")), ("whole", style("Wholed Sans 3", "Regular"))]));
|
||
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
|
||
let fonts = embedded(&o.svg);
|
||
assert_eq!(fonts.len(), 1, "{:?}", o.warnings);
|
||
assert_eq!(fonts[0].0, "\"Wholed Sans 3\"");
|
||
let size = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").len();
|
||
assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len());
|
||
assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings);
|
||
assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings);
|
||
}
|
||
|
||
proptest::proptest! {
|
||
/// Reading any bytes as SVG text never panics: byte order marks with odd or unpaired UTF-16,
|
||
/// declarations naming encodings, junk.
|
||
#[test]
|
||
fn any_bytes_decode_or_fail_cleanly(
|
||
head in proptest::sample::select(vec![&b""[..], &[0xfe, 0xff][..], &[0xff, 0xfe][..], &[0xef, 0xbb, 0xbf][..], &b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>"[..], &b"<?xml encoding='latin1"[..]]),
|
||
body in proptest::collection::vec(proptest::num::u8::ANY, 0..64),
|
||
) {
|
||
let bytes: Vec<u8> = head.iter().chain(&body).copied().collect();
|
||
let _ = text_of(&bytes);
|
||
let _ = text_of(&compress_bytes(&bytes));
|
||
}
|
||
}
|