//! 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(), inline: None }).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(""), "{svg}");
assert!(svg.contains(LINE) && svg.contains("
Grüße 中文"), "{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 = [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""), "{}", 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(" {
*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.extras_mut().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(""), "{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 [" Vec<(String, u16, Vec)> {
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::>(), [("\"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::>().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::>(), [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:?}");
}
/// Variable Font settings are written as `font-variation-settings`, with the `wght` setting as the
/// weight and the `wdth` setting as the width; the font embeds as a static font at the settings,
/// and the settings read back. Faces that one weight, style and width describe and that share
/// characters are warned about.
#[test]
fn variable_font_settings_are_written_embedded_and_read_back() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(two_axis_font().unwrap());
let set = [("wdth".to_string(), 90.0), ("wght".to_string(), 650.0)].into_iter().collect::>();
let varied = CharStyle { font_axes: set.clone(), ..style(FAMILY, "Regular") };
let mut d = Document::new(300.0, 200.0);
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(FAMILY, "Regular")), ("ll", varied.clone())]));
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
assert!(o.svg.contains("font-variation-settings:"wdth" 90, "wght" 650"), "{}", o.svg);
assert!(o.svg.contains("font-weight=\"650\"") && o.svg.contains("font-stretch=\"90%\""), "{}", o.svg);
let fonts = embedded(&o.svg);
assert_eq!(fonts.iter().map(|f| f.1).collect::>(), [400, 650], "{:?}", o.warnings);
assert!(o.svg.contains("font-style:normal;font-stretch:90%;src:"), "{}", o.svg);
assert!(o.warnings.is_empty(), "{:?}", o.warnings);
// The Condensed instance and Regular: one description, the same characters.
let mut same = Document::new(300.0, 200.0);
add(&mut same, text(Point::new(20.0, 60.0), &[("ll", style(FAMILY, "Regular")), ("ll", style(FAMILY, "Condensed"))]));
let warnings = export_full(&same, &ExportOptions { embed_fonts: true, ..Default::default() }, None).warnings;
assert!(warnings.iter().any(|w| w.contains(FAMILY) && w.contains("share characters")), "{warnings:?}");
let face = db.style_face(&varied).unwrap();
let f = skrifa::FontRef::new(&fonts[1].2).unwrap();
assert!(f.fvar().is_err() && f.gvar().is_err(), "a static font");
let adv = f.hmtx().unwrap().advance(f.cmap().unwrap().map_codepoint('l').unwrap()).unwrap();
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round());
let regular = db.face(FAMILY, "Regular").unwrap();
assert!(f64::from(adv) > regular.advance(regular.glyph_for('l')) + 5.0, "the instance's own glyphs");
// Read back: the settings, on the run that has them.
let back = import(&o.svg).unwrap();
let mut axes = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
axes.extend(t.runs.iter().map(|r| r.style.font_axes.clone()));
}
});
assert_eq!(axes, [Default::default(), set]);
}
/// 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 {
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::>();
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""[..], &b" = head.iter().chain(&body).copied().collect();
let _ = text_of(&bytes);
let _ = text_of(&compress_bytes(&bytes));
}
}