Files
vectorcraft/crates/svg/tests/import_text.rs
bflatasticandClaude Opus 5.5 303fc3cbca M4.42: SVG import reads linked images from the SVG's folder, intersects nested clips, keeps hidden objects and expands reflected gradients
document.open {path} (and File > Place) find an SVG's relative <image>
links in its folder: raster files stay linked images (LinkInfo with size,
time and hash), SVG files and data: SVGs import as vector art, and a file
that can't be read becomes a placeholder in its box that keeps the link,
with a warning and a missingLinks row (Update Links / Relink read it
later). The SVG library itself reads no file unless given a reader.
Nested clip paths become clip groups in clip groups; undisplayed objects
(display:none) come back hidden; spreadMethod reflect and repeat are
expanded into stops over the painted area (matching resvg). Midpoint stops
and focal points keep folding back as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:12:06 +07:00

388 lines
18 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SVG `<text>` import fidelity: placement in the tree, type on a path, per-character positions,
//! paints and the style cascade.
// 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_color::{GradientKind, Paint};
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_svg::{import, import_with_report};
use vectorcraft_text::{FontDb, layout};
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">{body}</svg>"#)
}
fn open(body: &str) -> Document {
import(&svg(body)).unwrap_or_else(|e| panic!("{e}"))
}
/// All non-layer nodes in paint order.
fn art(d: &Document) -> Vec<&Node> {
let mut v = Vec::new();
d.walk(|n| {
if !n.is_layer() {
v.push(n)
}
});
v
}
fn texts(d: &Document) -> Vec<&TextObject> {
art(d)
.into_iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(&**t),
_ => None,
})
.collect()
}
fn only_text(d: &Document) -> &TextObject {
let t = texts(d);
assert_eq!(t.len(), 1, "{:?}", art(d).iter().map(|n| &n.kind).collect::<Vec<_>>());
t[0]
}
/// Document-space pen position of each glyph.
fn glyph_origins(t: &TextObject) -> Vec<Point> {
layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin).collect()
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn text_keeps_its_z_order_between_shapes() {
let d = open(r#"<rect width="10" height="10"/><text x="5" y="20">Hi</text><rect y="30" width="10" height="10"/>"#);
let kinds: Vec<&str> = art(&d)
.iter()
.map(|n| match n.kind {
NodeKind::Text(_) => "text",
NodeKind::Path { .. } => "path",
_ => "other",
})
.collect();
assert_eq!(kinds, ["path", "text", "path"]);
let t = only_text(&d);
assert_eq!(t.plain_text(), "Hi");
assert!(t.xf.translation().to_point().distance(Point::new(5.0, 20.0)) < 1e-9);
}
#[test]
fn text_keeps_its_parent_group_clip_and_mask() {
let d = open(
r##"<defs><clipPath id="c"><rect width="50" height="50"/></clipPath>
<mask id="m"><rect width="100" height="100" fill="white"/></mask></defs>
<g id="outer" transform="translate(10 20)"><rect width="5" height="5"/>
<g clip-path="url(#c)"><text id="clipped" y="30">In clip</text></g>
<text id="own" y="60" clip-path="url(#c)" opacity="0.5">Own clip</text>
<text id="masked" y="90" mask="url(#m)">Masked</text>
</g><rect width="5" height="5"/>"##,
);
// Two top-level children (a group and a rect): no layers, everything stays on Layer 1.
assert_eq!(d.layers.len(), 1);
let top = d.layers[0].children().unwrap();
assert_eq!(top.len(), 2);
assert_eq!(top[0].name.as_deref(), Some("outer"));
let inner = top[0].children().unwrap();
assert_eq!(inner.len(), 4, "rect, clip group, clip group, masked text");
// `<g clip-path>` around the text → a clip group holding the clip path and the text.
let NodeKind::Group { children, clip: true } = &inner[1].kind else { panic!("{:?}", inner[1].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
let NodeKind::Text(t) = &children[1].kind else { panic!() };
assert_eq!(children[1].name.as_deref(), Some("clipped"));
assert!(t.xf.translation().to_point().distance(Point::new(10.0, 50.0)) < 1e-9, "{:?}", t.xf);
// The text's own clip-path and opacity.
let NodeKind::Group { children, clip: true } = &inner[2].kind else { panic!("{:?}", inner[2].kind) };
assert!((inner[2].opacity - 0.5).abs() < 1e-6);
assert!(matches!(children[1].kind, NodeKind::Text(_)) && children[1].name.as_deref() == Some("own"));
// The text's own mask.
assert!(matches!(inner[3].kind, NodeKind::Text(_)));
assert!(inner[3].mask.is_some());
assert_eq!(inner[3].name.as_deref(), Some("masked"));
}
#[test]
fn top_level_text_joins_the_layer_below_it() {
let d = open(r#"<g id="Back"><rect width="5" height="5"/></g><text y="20">Note</text><g id="Front"><rect width="5" height="5"/></g>"#);
assert_eq!(d.layers.iter().map(|l| l.name.as_deref().unwrap()).collect::<Vec<_>>(), ["Back", "Front"]);
let back = d.layers[0].children().unwrap();
assert_eq!(back.len(), 2);
assert!(matches!(back[1].kind, NodeKind::Text(_)));
// Text before the first layer goes to its bottom.
let d = open(r#"<text y="20">First</text><g id="Only"><rect width="5" height="5"/></g>"#);
let only = d.layers[0].children().unwrap();
assert!(matches!(only[0].kind, NodeKind::Text(_)) && matches!(only[1].kind, NodeKind::Path { .. }));
}
#[test]
fn hidden_text_is_skipped_and_use_instances_are_kept() {
let d = open(
r##"<text y="10" display="none">gone</text><g visibility="hidden"><text y="20">hidden</text></g>
<defs><text id="t" y="30">Used</text></defs><use href="#t" x="10"/><use xlink:href="#t" x="50"/>"##,
);
// An undisplayed text comes back as a hidden object (since SVG import keeps hidden objects).
let hidden: Vec<&Node> = art(&d).into_iter().filter(|n| !n.visible).collect();
assert!(matches!(hidden.as_slice(), [n] if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "gone")), "{hidden:?}");
let t: Vec<&TextObject> = art(&d)
.into_iter()
.filter(|n| n.visible)
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(&**t),
_ => None,
})
.collect();
assert_eq!(t.len(), 2);
assert!(t.iter().all(|t| t.plain_text() == "Used"));
assert!(t[0].xf.translation().to_point().distance(Point::new(10.0, 30.0)) < 1e-9);
assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9);
}
#[test]
fn text_path_becomes_type_on_a_path() {
let d = open(
r##"<path id="p" d="M10 100 L210 100" fill="none" stroke="black"/>
<text font-size="10"><textPath href="#p" startOffset="25%">Curve</textPath></text>
<text font-size="10"><textPath xlink:href="#p" startOffset="20">Twenty</textPath></text>
<text font-size="10" text-anchor="middle"><textPath href="#p" startOffset="50%">Mid</textPath></text>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 3);
let TextKind::OnPath { path, start } = &t[0].kind else { panic!("{:?}", t[0].kind) };
assert!(close(*start, 0.25, 1e-9));
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
assert!(close(b.x0, 10.0, 1e-6) && close(b.x1, 210.0, 1e-6) && close(b.y0, 100.0, 1e-6), "{b:?}");
assert_eq!(t[0].plain_text(), "Curve");
let TextKind::OnPath { start, .. } = &t[1].kind else { panic!() };
assert!(close(*start, 0.1, 1e-6), "{start}");
// Anchored in the middle: the text starts half its width before the offset.
let TextKind::OnPath { start, .. } = &t[2].kind else { panic!() };
let first = glyph_origins(t[2])[0];
let width: f64 = layout(FontDb::global(), t[2]).glyphs.iter().map(|g| g.advance).sum();
assert!(*start < 0.5 && close(first.x + width / 2.0, 110.0, 0.5), "{start} {first:?} {width}");
assert_eq!(t[2].para.justify, Justify::Left);
}
#[test]
fn per_character_positions_rotation_and_shifts() {
let d = open(r#"<text x="10 30 60" y="50" rotate="15 -30" dy="0 -4" font-size="12">abcd</text>"#);
let t = only_text(&d);
assert_eq!(t.plain_text(), "abcd");
let o = glyph_origins(t);
assert!(close(o[0].x, 10.0, 0.02) && close(o[1].x, 30.0, 0.02) && close(o[2].x, 60.0, 0.02), "{o:?}");
// `d` follows `c` at its natural advance.
assert!(o[3].x > 60.0 && o[3].x < 70.0, "{o:?}");
let chars: Vec<CharStyle> = t.runs.iter().flat_map(|r| r.text.chars().map(|_| r.style.clone())).collect();
let rot: Vec<f64> = chars.iter().map(|s| s.rotation).collect();
assert_eq!(rot, [-15.0, 30.0, 30.0, 30.0], "the last rotate value repeats");
let shift: Vec<f64> = chars.iter().map(|s| s.baseline_shift).collect();
assert_eq!(shift, [0.0, 4.0, 4.0, 4.0], "dy accumulates as baseline shift");
}
#[test]
fn per_character_positions_with_middle_anchor() {
// SVG anchors each absolutely placed chunk on its own: `a` is centred on 50, `b` on 100.
let d = open(r#"<text x="50 100" y="40" text-anchor="middle" font-size="20">ab</text>"#);
let t = only_text(&d);
let lay = layout(FontDb::global(), t);
// Natural advance: the kerning that moves `b` onto its x is added after `a`.
let natural = |i: usize| lay.glyphs[i].advance - t.runs[i].style.kerning.unwrap_or(0.0) / 1000.0 * t.runs[i].style.size;
let mid = |i: usize| (t.xf * lay.glyphs[i].origin).x + natural(i) / 2.0;
assert_eq!(t.runs.len(), 2);
assert!(close(mid(0), 50.0, 0.05) && close(mid(1), 100.0, 0.05), "{} {}", mid(0), mid(1));
}
#[test]
fn tspan_lines_dx_and_baseline_shift() {
let d = open(
r#"<text x="10" y="20" font-size="10">One<tspan x="10" dy="20">Two</tspan><tspan x="10" dy="12">Three</tspan>
<tspan dx="5">four</tspan><tspan baseline-shift="super">2</tspan><tspan baseline-shift="-3">x</tspan></text>"#,
);
let t = only_text(&d);
assert_eq!(t.plain_text(), "One\nTwo\nThree four2x");
let lay = layout(FontDb::global(), t);
let base: Vec<f64> = lay.lines.iter().map(|l| (t.xf * Point::new(0.0, l.baseline)).y).collect();
assert!(close(base[0], 20.0, 1e-6) && close(base[1], 40.0, 1e-6) && close(base[2], 52.0, 1e-6), "{base:?}");
let run = |s: &str| t.runs.iter().find(|r| r.text.contains(s)).unwrap().style.clone();
assert_eq!(run("Two").leading, Some(20.0));
assert_eq!(run("Three").leading, None, "12 pt is the auto leading of 10 pt type");
assert!(close(run("2").baseline_shift, 4.0, 1e-9) && close(run("x").baseline_shift, -3.0, 1e-9));
// dx on "four": the space before it gets 5 pt of kerning.
let sp = t.runs.iter().find(|r| r.text.ends_with(' ')).unwrap();
assert!(close(sp.style.kerning.unwrap(), 500.0, 1e-6), "{:?}", t.runs);
}
#[test]
fn weights_spacing_and_decoration() {
let d = open(
r#"<text y="20" font-size="10" font-weight="300" letter-spacing="1" word-spacing="2">a b<tspan font-weight="600">c</tspan><tspan font-weight="bolder">d</tspan><tspan font-weight="900" font-style="italic">e</tspan><tspan font-style="italic">f</tspan><tspan text-decoration="underline" font-weight="normal">g</tspan></text>"#,
);
let t = only_text(&d);
let style = |c: char| t.runs.iter().find(|r| r.text.contains(c)).unwrap().style.clone();
assert_eq!(style('a').font_style, "Light");
assert_eq!(style('c').font_style, "SemiBold");
assert_eq!(style('d').font_style, "Regular", "bolder than 300 is 400");
assert_eq!(style('e').font_style, "Black Italic");
assert_eq!(style('f').font_style, "Light Italic");
assert!(style('g').underline && style('g').font_style == "Regular");
assert!(close(style('a').tracking, 100.0, 1e-9));
assert!(close(style(' ').kerning.unwrap(), 200.0, 1e-9), "word spacing kerns spaces");
assert_eq!(style('a').kerning, None);
}
#[test]
fn css_selectors_reach_text() {
let d = open(
r#"<style>
text { font-family: Inter }
g text { fill: #ff0000 }
#big { font-size: 30px }
.a.b { fill: #00ff00 }
g > text.only-child { fill: #0000ff }
</style>
<text id="plain" y="10">p</text>
<g><text id="big" y="40">g</text><text class="a b" y="80">ab</text><text class="a" y="90">a</text><text class="only-child" y="100">c</text></g>"#,
);
let t = texts(&d);
let fill = |i: usize| t[i].first_style().fill.color().map(|c| c.to_hex());
assert_eq!(t[0].first_style().font_family, "Inter");
assert_eq!(fill(0).as_deref(), Some("#000000"));
assert_eq!(fill(1).as_deref(), Some("#ff0000"), "descendant selector");
assert!(close(t[1].first_style().size, 30.0, 1e-9), "#id selector");
assert_eq!(fill(2).as_deref(), Some("#00ff00"), "multiple classes");
assert_eq!(fill(3).as_deref(), Some("#ff0000"), "one of two classes doesn't match .a.b");
assert_eq!(fill(4).as_deref(), Some("#0000ff"), "child combinator");
}
#[test]
fn gradient_fill_resolves_in_text_space() {
let d = open(
r##"<defs>
<linearGradient id="user" gradientUnits="userSpaceOnUse" x1="20" y1="0" x2="120" y2="0"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient>
<linearGradient id="box"><stop offset="0" stop-color="#0f0"/><stop offset="1" stop-color="#000"/></linearGradient>
<radialGradient id="r"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></radialGradient>
</defs>
<text x="20" y="50" font-size="20" fill="url(#user)" stroke="url(#r)">Gradient<tspan fill="url(#box)">Box</tspan></text>"##,
);
let t = only_text(&d);
let Paint::Gradient(g) = &t.runs[0].style.fill else { panic!("{:?}", t.runs[0].style.fill) };
assert_eq!(g.gradient.kind, GradientKind::Linear);
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
// Text space has its origin at the text's x/y.
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(0.0, -50.0)) < 1e-6 && geom.end.distance(Point::new(100.0, -50.0)) < 1e-6, "{geom:?}");
let Paint::Gradient(s) = &t.runs[0].style.stroke else { panic!("{:?}", t.runs[0].style.stroke) };
assert_eq!(s.gradient.kind, GradientKind::Radial);
// objectBoundingBox: the gradient spans the laid-out text.
let Paint::Gradient(b) = &t.runs[1].style.fill else { panic!("{:?}", t.runs[1].style.fill) };
let bounds = layout(FontDb::global(), t).bounds;
let geom = b.geom.unwrap();
assert!(close(geom.start.x, bounds.x0, 1e-3) && close(geom.end.x, bounds.x1, 1e-3), "{geom:?} {bounds:?}");
assert_eq!(t.runs[1].text, "Box");
}
#[test]
fn pattern_fill_on_text_and_shapes() {
let d = open(
r##"<defs><pattern id="dots" width="10" height="10" patternUnits="userSpaceOnUse" x="2"><circle cx="5" cy="5" r="3" fill="red"/></pattern></defs>
<rect width="100" height="100" fill="url(#dots)"/><text x="10" y="50" fill="url(#dots)">P</text>"##,
);
assert_eq!(d.patterns.len(), 1);
let p = &d.patterns[0];
assert_eq!(p.name, "dots");
assert_eq!(p.tile, Rect::new(0.0, 0.0, 10.0, 10.0));
let NodeKind::Group { clip: true, children } = &p.art[0].kind else { panic!("content is clipped to the tile") };
assert_eq!(children.len(), 2);
let a = art(&d);
let Paint::Pattern { pattern, xf } = a[0].appearance.fill_paint() else { panic!() };
assert_eq!(pattern, "dots");
assert!(xf.translation().to_point().distance(Point::new(2.0, 0.0)) < 1e-9);
let t = only_text(&d);
let Paint::Pattern { pattern, xf } = &t.runs[0].style.fill else { panic!("{:?}", t.runs[0].style.fill) };
assert_eq!(pattern, "dots");
// In text space (origin at 10, 50) the tiles stay where they are in the document.
assert!(xf.translation().to_point().distance(Point::new(-8.0, -50.0)) < 1e-9, "{xf:?}");
}
#[test]
fn vertical_writing_mode_runs_down_a_path() {
let d = open(r#"<text x="100" y="20" writing-mode="tb" font-size="10">Down</text>"#);
let t = only_text(&d);
let TextKind::OnPath { path, start } = &t.kind else { panic!("{:?}", t.kind) };
assert_eq!(*start, 0.0);
let b = path.bounds().unwrap();
assert!(close(b.x0, b.x1, 1e-9) && close(b.y0, 20.0, 1e-9) && b.height() > 20.0, "{b:?}");
let o = glyph_origins(t);
assert!(o.windows(2).all(|w| w[1].y > w[0].y), "glyphs step down: {o:?}");
// The em box is centred on the column.
let lay = layout(FontDb::global(), t);
assert!(lay.bounds.x0 < 100.0 && lay.bounds.x1 > 100.0, "{:?}", lay.bounds);
}
#[test]
fn unusable_text_path_falls_back_to_point_type_with_a_warning() {
let (d, w) = import_with_report(&svg(r##"<text x="5" y="10"><textPath href="#missing">Lost</textPath></text>"##)).unwrap();
let t = only_text(&d);
assert!(matches!(t.kind, TextKind::Point));
assert_eq!(t.plain_text(), "Lost");
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
}
#[test]
fn white_space_handling() {
let d = open("<text y=\"10\"> Hello\n big\tworld </text><text y=\"30\" xml:space=\"preserve\"> a b </text>");
let t = texts(&d);
assert_eq!(t[0].plain_text(), "Hello big world");
assert_eq!(t[1].plain_text(), " a b ");
}
#[test]
fn placeholder_ids_never_clash_with_the_files() {
let d = open(r#"<rect id="vectorcraft-text-0" width="5" height="5"/><text y="20">Safe</text>"#);
assert_eq!(only_text(&d).plain_text(), "Safe");
assert_eq!(art(&d)[0].name.as_deref(), Some("vectorcraft-text-0"));
}
#[test]
fn linked_text_keeps_its_url_and_stroke_style() {
let d = open(
r#"<rect width="5" height="5"/><a href="https://example.com/t"><text y="20" stroke="red" stroke-width="0.1em" stroke-linejoin="round" stroke-dasharray="2 1">Go</text></a>"#,
);
let n = art(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
assert_eq!(n.url(), Some("https://example.com/t"));
let st = only_text(&d).first_style();
assert!(close(st.stroke_width, 1.2, 1e-9), "{}", st.stroke_width);
assert_eq!(st.stroke_join, vectorcraft_doc::LineJoin::Round);
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0]));
}
#[test]
fn plain_texts_among_siblings_stay_texts() {
let d = open(
r#"<g><rect width="5" height="5"/><text y="20">A</text><g><text y="30">B</text></g><rect y="40" width="5" height="5"/></g><g><text y="50">C</text></g>"#,
);
let kinds: Vec<&str> = art(&d)
.iter()
.map(|n| match n.kind {
NodeKind::Text(_) => "text",
NodeKind::Path { .. } => "path",
NodeKind::Group { .. } => "group",
_ => "other",
})
.collect();
assert_eq!(texts(&d).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["A", "B", "C"], "{kinds:?}");
assert_eq!(kinds.iter().filter(|k| **k == "path").count(), 2, "{kinds:?}");
}
#[test]
fn far_off_positions_stay_finite() {
let d = open(r#"<text x="1e308 -1e308 1e308" y="1e308" dx="1e308 1e308" dy="1e308 1e308" rotate="inf 5" text-anchor="middle">abc</text>"#);
let t = only_text(&d);
assert!(t.xf.as_coeffs().iter().all(|v| v.is_finite()), "{:?}", t.xf);
for r in &t.runs {
assert!(r.style.baseline_shift.is_finite() && r.style.kerning.is_none_or(f64::is_finite) && r.style.rotation.is_finite(), "{r:?}");
}
}