mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
SVG import comes in editable and looking right: groups named from their ids with transforms baked into the layers (rotated, scaled and skewed groups, viewBox scaling and <use> offsets used to be lost); rectangles, ellipses and lines recognised and kept as those shapes; gradients with exact handles, reflect/repeat and focal points; patterns as image fills; clip paths and masks as vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows and layer blurs; blend modes; text as text layers (it used to vanish); images and nested SVG images. What can't be represented is reported as import warnings (file.import / file.open warnings, figcraft-cli info notes). Linked files are never read, and documents nested too deep for the parser are refused instead of crashing. Pasted SVG is recognised after XML declarations, comments and doctypes and lands centred in the view (file.import center). SVG export writes vector masks as clip paths and alpha/luminance masks as the mask layer drawn, so masks survive a round trip. Tests: a corpus of 21 synthetic SVG files with hand-worked expected pixels (158 probes), structure checks and export round trips; unit tests for shape recognition, paints, filters, structure, mask export; hostile input and byte-mangled corpus fuzzing; engine file command tests. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
18 KiB
Rust
435 lines
18 KiB
Rust
//! The SVG import corpus: synthetic files covering what design and illustration tools write.
|
||
//!
|
||
//! Each `tests/corpus/*.svg` carries its expected pixels as `<!-- probe X Y #RRGGBBAA ~TOL -->`
|
||
//! comments, worked out by hand from the SVG. The import is rendered with FigCraft's renderer and
|
||
//! every probe checked; the layer structure is checked per file; and every file must survive an
|
||
//! export and a second import looking the same.
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||
use figcraft_render::{View, to_rgba8};
|
||
use figcraft_svg::{SvgOptions, export_svg, import_svg};
|
||
|
||
const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/corpus");
|
||
|
||
fn corpus() -> Vec<(String, String)> {
|
||
let mut v: Vec<(String, String)> = std::fs::read_dir(DIR)
|
||
.unwrap()
|
||
.map(|e| e.unwrap().path())
|
||
.filter(|p| p.extension().is_some_and(|x| x == "svg"))
|
||
.map(|p| (p.file_stem().unwrap().to_string_lossy().into_owned(), std::fs::read_to_string(&p).unwrap()))
|
||
.collect();
|
||
v.sort();
|
||
assert!(v.len() >= 15, "corpus missing");
|
||
v
|
||
}
|
||
|
||
fn source(name: &str) -> String {
|
||
std::fs::read_to_string(format!("{DIR}/{name}.svg")).unwrap()
|
||
}
|
||
|
||
pub fn import(src: &[u8]) -> (Document, NodeId) {
|
||
let mut d = Document::new("corpus");
|
||
let p = d.first_page().unwrap();
|
||
let f = import_svg(&mut d, src, p, 0.0, 0.0, "Imported").unwrap();
|
||
assert!(d.validate().is_empty(), "{:?}", d.validate());
|
||
(d, f)
|
||
}
|
||
|
||
/// The frame's area rendered at 1x, straight RGBA.
|
||
fn render(d: &Document, frame: NodeId) -> (Vec<u8>, u32, u32) {
|
||
let n = d.node(frame).unwrap();
|
||
let (w, h) = (n.width.ceil().max(1.0) as u32, n.height.ceil().max(1.0) as u32);
|
||
let o = d.world_transform(frame).translation();
|
||
let view = View { origin: (o.x, o.y), scale: 1.0, width: w, height: h };
|
||
let mut pix = figcraft_render::Pixmap::new(w, h).unwrap();
|
||
figcraft_render::render_nodes_into(d, &[frame], &view, &mut pix);
|
||
(to_rgba8(&pix), w, h)
|
||
}
|
||
|
||
struct Probe {
|
||
x: u32,
|
||
y: u32,
|
||
rgba: [u8; 4],
|
||
tol: u8,
|
||
}
|
||
|
||
fn probes(src: &str) -> Vec<Probe> {
|
||
src.lines()
|
||
.filter_map(|l| {
|
||
let l = l.trim().strip_prefix("<!-- probe ")?.strip_suffix("-->")?;
|
||
let mut it = l.split_whitespace();
|
||
let x = it.next()?.parse().ok()?;
|
||
let y = it.next()?.parse().ok()?;
|
||
let hex = it.next()?.strip_prefix('#')?;
|
||
let b = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok();
|
||
let rgba = [b(0)?, b(2)?, b(4)?, b(6)?];
|
||
let tol = it.next().and_then(|t| t.strip_prefix('~')).and_then(|t| t.parse().ok()).unwrap_or(8);
|
||
Some(Probe { x, y, rgba, tol })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn pixel(px: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
|
||
let i = ((y * w + x) * 4) as usize;
|
||
[px[i], px[i + 1], px[i + 2], px[i + 3]]
|
||
}
|
||
|
||
/// Probe mismatches of `name` as messages.
|
||
fn check(name: &str, src: &str, d: &Document, f: NodeId) -> Vec<String> {
|
||
let (px, w, h) = render(d, f);
|
||
let mut bad = Vec::new();
|
||
for p in probes(src) {
|
||
if p.x >= w || p.y >= h {
|
||
bad.push(format!("{name}: probe {} {} outside {w}x{h}", p.x, p.y));
|
||
continue;
|
||
}
|
||
let got = pixel(&px, w, p.x, p.y);
|
||
let diff = |i: usize| got[i].abs_diff(p.rgba[i]);
|
||
// Colour only means something where there's enough coverage.
|
||
let ok = if p.rgba[3] < 32 { diff(3) <= p.tol } else { (0..4).all(|i| diff(i) <= p.tol) };
|
||
if !ok {
|
||
bad.push(format!("{name}: probe {} {} expected {:02x?} got {:02x?} (±{})", p.x, p.y, p.rgba, got, p.tol));
|
||
}
|
||
}
|
||
bad
|
||
}
|
||
|
||
#[test]
|
||
fn corpus_renders_match_the_probes() {
|
||
let mut bad = Vec::new();
|
||
let mut count = 0;
|
||
for (name, src) in corpus() {
|
||
let (d, f) = import(src.as_bytes());
|
||
count += probes(&src).len();
|
||
bad.extend(check(&name, &src, &d, f));
|
||
}
|
||
assert!(count > 140, "{count} probes");
|
||
assert!(bad.is_empty(), "{} of {count} probes failed:\n{}", bad.len(), bad.join("\n"));
|
||
}
|
||
|
||
/// Mean absolute difference per channel between two renders of the same size.
|
||
fn mean_diff(a: &[u8], b: &[u8]) -> f64 {
|
||
let sum: u64 = a.iter().zip(b).map(|(x, y)| u64::from(x.abs_diff(*y))).sum();
|
||
sum as f64 / a.len().max(1) as f64
|
||
}
|
||
|
||
#[test]
|
||
fn corpus_round_trips_through_export() {
|
||
let mut bad = Vec::new();
|
||
for (name, src) in corpus() {
|
||
let (d, f) = import(src.as_bytes());
|
||
let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap();
|
||
let (d2, f2) = import(svg.as_bytes());
|
||
let (a, w, h) = render(&d, f);
|
||
let (b, w2, h2) = render(&d2, f2);
|
||
if (w, h) != (w2, h2) {
|
||
bad.push(format!("{name}: size {w}x{h} became {w2}x{h2}"));
|
||
continue;
|
||
}
|
||
let m = mean_diff(&a, &b);
|
||
if m > 1.5 {
|
||
bad.push(format!("{name}: mean difference {m:.2} after export and import"));
|
||
}
|
||
// The probes still hold on the second import.
|
||
bad.extend(check(&format!("{name} (round trip)"), &src, &d2, f2).into_iter().filter(|_| !name.starts_with("text")));
|
||
// And exporting again gives the same file.
|
||
let again = export_svg(&d2, &[f2], &SvgOptions::default()).unwrap();
|
||
let (d3, f3) = import(again.as_bytes());
|
||
let (c, ..) = render(&d3, f3);
|
||
if mean_diff(&b, &c) > 0.5 {
|
||
bad.push(format!("{name}: not stable on a second round trip"));
|
||
}
|
||
}
|
||
assert!(bad.is_empty(), "{}", bad.join("\n"));
|
||
}
|
||
|
||
/// Writes each import's render and layer tree to `$SVG_DUMP_DIR` (run with `--ignored`).
|
||
#[test]
|
||
#[ignore]
|
||
fn dump() {
|
||
let Ok(dir) = std::env::var("SVG_DUMP_DIR") else { return };
|
||
for (name, src) in corpus() {
|
||
let (d, f) = import(src.as_bytes());
|
||
let (px, w, h) = render(&d, f);
|
||
let img = image_png(&px, w, h);
|
||
std::fs::write(format!("{dir}/{name}.png"), img).unwrap();
|
||
let mut tree = String::new();
|
||
outline(&d, f, 0, &mut tree);
|
||
std::fs::write(format!("{dir}/{name}.txt"), tree).unwrap();
|
||
}
|
||
}
|
||
|
||
fn image_png(px: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||
let mut pix = figcraft_render::Pixmap::new(w, h).unwrap();
|
||
for (i, c) in px.chunks(4).enumerate() {
|
||
let a = u32::from(c[3]);
|
||
let pm = |v: u8| ((u32::from(v) * a + 127) / 255) as u8;
|
||
let d = pix.data_mut();
|
||
d[i * 4] = pm(c[0]);
|
||
d[i * 4 + 1] = pm(c[1]);
|
||
d[i * 4 + 2] = pm(c[2]);
|
||
d[i * 4 + 3] = c[3];
|
||
}
|
||
figcraft_render::encode_png(&pix).unwrap()
|
||
}
|
||
|
||
fn outline(d: &Document, id: NodeId, depth: usize, out: &mut String) {
|
||
let n = d.node(id).unwrap();
|
||
let c = n.transform.as_coeffs();
|
||
let extra = match &n.kind {
|
||
NodeKind::Text(t) => format!(" {:?} {} {} {:?}", t.characters, t.style.font_family, t.style.font_size, t.align_h),
|
||
_ => String::new(),
|
||
};
|
||
out.push_str(&format!(
|
||
"{}{} {:?} {:.1}x{:.1} [{:.2} {:.2} {:.2} {:.2} {:.1} {:.1}] fills={} strokes={} fx={} mask={} op={}{extra}\n",
|
||
" ".repeat(depth),
|
||
n.kind.type_name(),
|
||
n.name,
|
||
n.width,
|
||
n.height,
|
||
c[0],
|
||
c[1],
|
||
c[2],
|
||
c[3],
|
||
c[4],
|
||
c[5],
|
||
n.fills.len(),
|
||
n.strokes.len(),
|
||
n.effects.len(),
|
||
n.is_mask,
|
||
n.opacity
|
||
));
|
||
for k in &n.children {
|
||
outline(d, *k, depth + 1, out);
|
||
}
|
||
}
|
||
|
||
// ---- Layer structure: editable and sensible ----
|
||
|
||
fn imported(name: &str) -> (Document, NodeId) {
|
||
import(source(name).as_bytes())
|
||
}
|
||
|
||
/// The first layer called `name` under `f`.
|
||
fn layer<'a>(d: &'a Document, f: NodeId, name: &str) -> &'a figcraft_doc::Node {
|
||
d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name:?}"))
|
||
}
|
||
|
||
fn kinds(d: &Document, f: NodeId) -> Vec<&'static str> {
|
||
d.subtree(f).into_iter().skip(1).filter_map(|i| d.node(i)).map(|n| n.kind.type_name()).collect()
|
||
}
|
||
|
||
fn count(d: &Document, f: NodeId, kind: &str) -> usize {
|
||
kinds(d, f).iter().filter(|k| **k == kind).count()
|
||
}
|
||
|
||
#[test]
|
||
fn groups_keep_their_ids_and_transforms_bake_into_shapes() {
|
||
let (d, f) = imported("transforms");
|
||
for g in ["moved", "turned", "scaled", "skewed"] {
|
||
assert_eq!(layer(&d, f, g).kind.type_name(), "GROUP");
|
||
}
|
||
// Rotated: still a 40×40 rectangle, turned 45°.
|
||
let b = layer(&d, f, "b");
|
||
assert_eq!(b.kind.type_name(), "RECTANGLE");
|
||
assert!((b.width - 40.0).abs() < 1e-6 && (b.height - 40.0).abs() < 1e-6);
|
||
let c = d.world_transform(d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == "b")).unwrap()).as_coeffs();
|
||
assert!((c[1].atan2(c[0]).to_degrees() - 45.0).abs() < 1e-6, "{c:?}");
|
||
// Scaled 2 × 1.5: the size takes it.
|
||
let sc = layer(&d, f, "c");
|
||
assert_eq!((sc.kind.type_name(), sc.width.round(), sc.height.round()), ("RECTANGLE", 40.0, 30.0));
|
||
// Skewed: only a vector can take it.
|
||
assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR");
|
||
}
|
||
|
||
#[test]
|
||
fn basic_shapes_stay_rectangles_ellipses_and_lines() {
|
||
let (d, f) = imported("primitives");
|
||
let card = layer(&d, f, "card");
|
||
assert_eq!((card.kind.type_name(), card.width, card.height), ("RECTANGLE", 50.0, 30.0));
|
||
assert_eq!(card.corners.radii, [8.0; 4]);
|
||
assert_eq!(count(&d, f, "ELLIPSE"), 2);
|
||
assert_eq!(count(&d, f, "LINE"), 2);
|
||
// Elliptical corners, a polyline and a polygon are vectors.
|
||
assert_eq!(count(&d, f, "VECTOR"), 3);
|
||
let line = d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.kind.type_name() == "LINE").unwrap();
|
||
assert_eq!((line.width, line.stroke.weight, line.strokes.len()), (180.0, 2.0, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn strokes_keep_dashes_caps_and_joins() {
|
||
use figcraft_doc::{StrokeCap, StrokeJoin};
|
||
let (d, f) = imported("strokes");
|
||
assert_eq!(layer(&d, f, "dashed").stroke.dashes, vec![20.0, 10.0]);
|
||
assert_eq!(layer(&d, f, "round-cap").stroke.cap_end, StrokeCap::Round);
|
||
assert_eq!(layer(&d, f, "square-cap").stroke.cap_start, StrokeCap::Square);
|
||
assert_eq!(layer(&d, f, "butt-cap").stroke.cap_start, StrokeCap::None);
|
||
assert_eq!(layer(&d, f, "round-join").stroke.join, StrokeJoin::Round);
|
||
assert_eq!(layer(&d, f, "bevel").stroke.join, StrokeJoin::Bevel);
|
||
assert_eq!(layer(&d, f, "miter").stroke.miter_limit, 4.0);
|
||
}
|
||
|
||
#[test]
|
||
fn gradients_stay_gradients() {
|
||
use figcraft_color::{GradientKind, PaintKind};
|
||
let (d, f) = imported("gradients");
|
||
for (name, kind) in
|
||
[("bbox-units", GradientKind::Linear), ("rotated", GradientKind::Linear), ("radial", GradientKind::Radial), ("focal", GradientKind::Radial)]
|
||
{
|
||
let n = layer(&d, f, name);
|
||
assert!(matches!(&n.fills[0].kind, PaintKind::Gradient(g) if g.kind == kind), "{name}: {:?}", n.fills);
|
||
}
|
||
// Reflect over four periods: the stops are unrolled.
|
||
let PaintKind::Gradient(g) = &layer(&d, f, "reflect").fills[0].kind else { panic!() };
|
||
assert_eq!(g.stops.len(), 8, "{:?}", g.stops);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_paths_and_masks_become_mask_layers() {
|
||
use figcraft_doc::MaskType;
|
||
let (d, f) = imported("clip_mask");
|
||
let mask_of = |name: &str| {
|
||
let id = d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == name)).unwrap();
|
||
let parent = d.node(d.node(id).unwrap().parent.unwrap()).unwrap();
|
||
let m = d.node(parent.children[0]).unwrap();
|
||
assert!(m.is_mask, "{name}: {parent:?}");
|
||
(m.kind.type_name(), m.mask_type, parent.name.clone())
|
||
};
|
||
assert_eq!(mask_of("clipped"), ("ELLIPSE", MaskType::Vector, "Mask group".into()));
|
||
assert_eq!(mask_of("masked"), ("GROUP", MaskType::Luminance, "Mask group".into()));
|
||
assert_eq!(mask_of("faded").1, MaskType::Luminance);
|
||
assert_eq!(mask_of("alpha-masked").1, MaskType::Alpha);
|
||
// The clipped group keeps its name, with the clip at the bottom.
|
||
let g = layer(&d, f, "clipped-group");
|
||
assert!(d.node(g.children[0]).unwrap().is_mask && g.children.len() == 3);
|
||
}
|
||
|
||
#[test]
|
||
fn filters_become_effects() {
|
||
use figcraft_doc::EffectKind;
|
||
let (d, f) = imported("filters");
|
||
let fx = |name: &str| layer(&d, f, name).effects.iter().map(|e| e.kind.clone()).collect::<Vec<_>>();
|
||
assert!(matches!(&fx("blurred")[..], [EffectKind::LayerBlur(b)] if (b.radius - 8.0).abs() < 1e-6));
|
||
let [EffectKind::DropShadow(s)] = &fx("dropped")[..] else { panic!("{:?}", fx("dropped")) };
|
||
assert_eq!((s.offset, s.radius, s.color.a), ((4.0, 6.0), 4.0, 0.5));
|
||
let [EffectKind::DropShadow(s)] = &fx("classic-shadow")[..] else { panic!("{:?}", fx("classic-shadow")) };
|
||
assert_eq!((s.offset, s.radius), ((0.0, 4.0), 6.0));
|
||
assert!((s.color.r - 1.0).abs() < 1e-6 && (s.color.a - 0.25).abs() < 1e-6);
|
||
let [EffectKind::DropShadow(drop), EffectKind::InnerShadow(inner)] = &fx("matrixed")[..] else { panic!("{:?}", fx("matrixed")) };
|
||
assert_eq!((drop.offset, drop.radius, inner.offset, inner.radius), ((2.0, 2.0), 2.0, (0.0, -3.0), 3.0));
|
||
assert!(!drop.show_behind, "cut out under the layer");
|
||
assert!(fx("unsupported").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_parts_are_reported() {
|
||
let mut d = Document::new("t");
|
||
let p = d.first_page().unwrap();
|
||
let imported = figcraft_svg::import_svg_with_notes(&mut d, source("filters").as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||
assert!(imported.notes.iter().any(|n| n.contains("noise")), "{:?}", imported.notes);
|
||
let clean = figcraft_svg::import_svg_with_notes(&mut d, source("primitives").as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||
assert!(clean.notes.is_empty(), "{:?}", clean.notes);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_modes_opacity_and_hidden_layers() {
|
||
use figcraft_color::BlendMode;
|
||
let (d, f) = imported("blend_visibility");
|
||
assert_eq!(layer(&d, f, "multiplied").blend, BlendMode::Multiply);
|
||
assert_eq!(layer(&d, f, "screened").blend, BlendMode::Screen);
|
||
for gone in ["gone", "hidden"] {
|
||
assert!(d.subtree(f).into_iter().all(|i| d.node(i).is_some_and(|n| n.name != gone)), "{gone}");
|
||
}
|
||
assert_eq!(layer(&d, f, "shown").kind.type_name(), "RECTANGLE");
|
||
let (d, f) = imported("opacity");
|
||
assert_eq!(layer(&d, f, "faded").opacity, 0.5);
|
||
}
|
||
|
||
#[test]
|
||
fn sizes_follow_units_and_view_boxes() {
|
||
let size = |name: &str| {
|
||
let (d, f) = imported(name);
|
||
let n = d.node(f).unwrap();
|
||
(n.width.round(), n.height.round())
|
||
};
|
||
assert_eq!(size("units"), (100.0, 96.0));
|
||
assert_eq!(size("viewbox_meet"), (200.0, 100.0));
|
||
assert_eq!(size("illustration"), (120.0, 80.0));
|
||
assert_eq!(size("icon"), (48.0, 48.0));
|
||
// The icon's 2 px strokes on a 24 grid drawn at 48 px are 4 px.
|
||
let (d, f) = imported("icon");
|
||
let ring = d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.kind.type_name() == "ELLIPSE").unwrap();
|
||
assert_eq!((ring.width, ring.stroke.weight, ring.fills.len()), (36.0, 4.0, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn use_and_symbols_resolve_without_extra_wrappers() {
|
||
let (d, f) = imported("use_symbol");
|
||
assert_eq!(kinds(&d, f), ["ELLIPSE", "RECTANGLE", "RECTANGLE"]);
|
||
let (d, f) = imported("illustration");
|
||
assert_eq!(layer(&d, f, "Walls").kind.type_name(), "GROUP");
|
||
assert!(d.subtree(f).into_iter().all(|i| !d.node(i).unwrap().is_mask));
|
||
}
|
||
|
||
#[test]
|
||
fn images_are_image_fills() {
|
||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||
let (d, f) = imported("image");
|
||
let photo = layer(&d, f, "photo");
|
||
assert_eq!((photo.kind.type_name(), photo.width, photo.height), ("RECTANGLE", 40.0, 20.0));
|
||
assert!(matches!(&photo.fills[0].kind, PaintKind::Image(i) if i.scale_mode == ImageScaleMode::Fill));
|
||
assert_eq!(d.images.len(), 1, "the same image is stored once");
|
||
assert_eq!(d.images.values().next().map(|i| (i.width, i.height)), Some((4, 2)));
|
||
// Patterns: a repeating tile, and images (drawn or embedded) shown once.
|
||
let (d, f) = imported("patterns");
|
||
let mode = |name: &str| match &layer(&d, f, name).fills[0].kind {
|
||
PaintKind::Image(i) => i.scale_mode,
|
||
k => panic!("{name}: {k:?}"),
|
||
};
|
||
assert_eq!((mode("tiled"), mode("spotted"), mode("pictured")), (ImageScaleMode::Tile, ImageScaleMode::Fill, ImageScaleMode::Fill));
|
||
}
|
||
|
||
#[test]
|
||
fn text_becomes_text_layers() {
|
||
use figcraft_doc::{LetterSpacing, LineHeight, TextAlignH};
|
||
let (d, f) = imported("text");
|
||
let texts: Vec<&figcraft_doc::Node> = d.subtree(f).into_iter().filter_map(|i| d.node(i)).filter(|n| n.kind.text().is_some()).collect();
|
||
if figcraft_text::fontdb::find_face("Inter", "Regular").is_none() {
|
||
// Without any font usvg can't lay text out (CI without fonts).
|
||
return;
|
||
}
|
||
assert_eq!(texts.len(), 5, "{:?}", texts.iter().map(|t| &t.name).collect::<Vec<_>>());
|
||
let t = |name: &str| layer(&d, f, name).kind.text().unwrap().clone();
|
||
let title = t("title");
|
||
assert_eq!((title.characters.as_str(), title.style.font_size, title.style.font_style.as_str()), ("Hello", 24.0, "Bold"));
|
||
let sans = ["Inter", "Helvetica", "Arial", "Segoe UI", "Roboto", "Noto Sans", "DejaVu Sans", "Liberation Sans"];
|
||
assert!(sans.contains(&title.style.font_family.as_str()), "{}", title.style.font_family);
|
||
let centered = t("Centered");
|
||
assert_eq!((centered.align_h, centered.style.letter_spacing), (TextAlignH::Center, LetterSpacing::Pixels(2.0)));
|
||
assert_eq!(t("End").align_h, TextAlignH::Right);
|
||
assert_eq!(t("End").style.font_style, "Italic");
|
||
// Two tspans 20 apart: one layer, two lines.
|
||
let lines = t("Line one");
|
||
assert_eq!((lines.characters.as_str(), lines.style.line_height), ("Line one\nLine two", LineHeight::Pixels(20.0)));
|
||
// A bold green tspan: a run.
|
||
let run = t("Plain bold run");
|
||
assert_eq!(run.runs.len(), 1, "{:?}", run.runs);
|
||
assert_eq!((run.runs[0].start, run.runs[0].end, run.runs[0].style.font_style.as_str()), (6, 10, "Bold"));
|
||
assert!(run.runs[0].style.fills.is_some());
|
||
// The first baseline sits on y; the anchor on x.
|
||
for (name, x, y) in [("title", 10.0, 40.0), ("Centered", 150.0, 80.0), ("End", 290.0, 120.0)] {
|
||
let id = d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == name)).unwrap();
|
||
let n = d.node(id).unwrap();
|
||
let (l, _) = figcraft_text::layout_node(n).unwrap();
|
||
let o = d.world_transform(id).translation();
|
||
let k = match n.kind.text().unwrap().align_h {
|
||
TextAlignH::Center => 0.5,
|
||
TextAlignH::Right => 1.0,
|
||
_ => 0.0,
|
||
};
|
||
assert!((o.x + k * n.width - x).abs() < 0.01, "{name}: x {}", o.x);
|
||
assert!((o.y + l.lines[0].baseline - y).abs() < 0.01, "{name}: y {}", o.y);
|
||
}
|
||
}
|