vectorcraft/crates/svg/tests/import_text.rs

615 lines
30 KiB
Rust
Raw 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].extras().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:?}");
}
}
/// Drawn bounds of the laid-out text in the document.
fn drawn(t: &TextObject) -> Rect {
t.xf.transform_rect_bbox(layout(FontDb::global(), t).bounds)
}
/// The linear part of a transform.
fn linear(t: &TextObject) -> [f64; 4] {
let [a, b, c, d, ..] = t.xf.as_coeffs();
[a, b, c, d]
}
fn close4(a: [f64; 4], b: [f64; 4]) -> bool {
// usvg's transforms are single precision.
a.iter().zip(b).all(|(a, b)| close(*a, b, 1e-6))
}
#[test]
fn a_scale_above_text_becomes_its_size() {
// #396: a group's or the text's own scale is the size the type shows and draws at.
let d = open(
r#"<text x="50" y="100" font-size="7">HHHH Hamburg</text>
<g transform="scale(3)"><text x="16" y="60" font-size="7">HHHH Hamburg</text></g>
<text x="50" y="400" font-size="7" transform="scale(2)">HHHH Hamburg</text>"#,
);
let t = texts(&d);
let sizes: Vec<f64> = t.iter().map(|t| t.first_style().size).collect();
assert!(close(sizes[0], 7.0, 1e-9) && close(sizes[1], 21.0, 1e-9) && close(sizes[2], 14.0, 1e-9), "{sizes:?}");
for t in &t {
assert!(close4(linear(t), [1.0, 0.0, 0.0, 1.0]), "{:?}", t.xf);
}
assert!(t[1].xf.translation().to_point().distance(Point::new(48.0, 180.0)) < 1e-9, "{:?}", t[1].xf);
assert!(t[2].xf.translation().to_point().distance(Point::new(100.0, 800.0)) < 1e-9, "{:?}", t[2].xf);
// Drawn as large as their sizes say.
let h: Vec<f64> = t.iter().map(|t| drawn(t).height() / t.first_style().size).collect();
assert!(close(h[1], h[0], 1e-6) && close(h[2], h[0], 1e-6), "{h:?}");
}
#[test]
fn a_viewbox_scale_becomes_the_size() {
let d = import(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="306" height="396" viewBox="0 0 612 792"><text x="50" y="100" font-size="7">HH</text></svg>"#,
)
.unwrap();
let t = only_text(&d);
assert!(close(t.first_style().size, 3.5, 1e-9), "{}", t.first_style().size);
assert!(close4(linear(t), [1.0, 0.0, 0.0, 1.0]), "{:?}", t.xf);
assert!(t.xf.translation().to_point().distance(Point::new(25.0, 50.0)) < 1e-9, "{:?}", t.xf);
}
#[test]
fn a_rotated_scale_scales_every_length_and_keeps_the_rotation() {
// The same text written at twice the size: the scale moves into size, leading, baseline
// shift and the character strokes; spacing in ems stays.
let scaled = open(
r#"<text transform="translate(100 50) rotate(30) scale(2)" x="5" y="10" font-size="7" letter-spacing="1" word-spacing="2" stroke="red" stroke-width="0.5" stroke-dasharray="1 2">Ab <tspan baseline-shift="3">c</tspan><tspan x="5" dy="12">Two</tspan></text>"#,
);
let written = open(
r#"<text transform="translate(100 50) rotate(30)" x="10" y="20" font-size="14" letter-spacing="2" word-spacing="4" stroke="red" stroke-width="1" stroke-dasharray="2 4">Ab <tspan baseline-shift="6">c</tspan><tspan x="10" dy="24">Two</tspan></text>"#,
);
let (s, w) = (only_text(&scaled), only_text(&written));
assert_eq!(s.runs, w.runs);
assert!(s.runs.iter().any(|r| r.style.leading == Some(24.0) && r.text.contains("Two")), "{:?}", s.runs);
assert!(close(s.xf.determinant(), 1.0, 1e-6), "{:?}", s.xf);
let r = 30f64.to_radians();
assert!(close4(linear(s), [r.cos(), r.sin(), -r.sin(), r.cos()]), "{:?}", s.xf);
for (a, b) in glyph_origins(s).iter().zip(glyph_origins(w)) {
assert!(a.distance(b) < 1e-6, "{a:?} {b:?}");
}
}
#[test]
fn a_stretch_or_skew_stays_the_transform() {
// The scale across the baseline is the size; a stretch along it or a skew stays.
let d = open(
r#"<text x="10" y="20" font-size="7" transform="scale(3 1)">HH</text>
<text x="10" y="20" font-size="7" transform="scale(1 3)">HH</text>
<text x="10" y="20" font-size="7" transform="skewX(20)">HH</text>"#,
);
let t = texts(&d);
let sizes: Vec<f64> = t.iter().map(|t| t.first_style().size).collect();
assert!(close(sizes[0], 7.0, 1e-9) && close(sizes[1], 21.0, 1e-9) && close(sizes[2], 7.0, 1e-9), "{sizes:?}");
assert!(close4(linear(t[0]), [3.0, 0.0, 0.0, 1.0]), "{:?}", t[0].xf);
assert!(close4(linear(t[1]), [1.0 / 3.0, 0.0, 0.0, 1.0]), "{:?}", t[1].xf);
assert!(close4(linear(t[2]), [1.0, 0.0, 20f64.to_radians().tan(), 1.0]), "{:?}", t[2].xf);
// `scale(1 3)` draws three times as tall as `scale(3 1)`, a third as wide.
let (wide, tall) = (drawn(t[0]), drawn(t[1]));
assert!(close(tall.height(), 3.0 * wide.height(), 1e-6) && close(3.0 * tall.width(), wide.width(), 1e-6), "{wide:?} {tall:?}");
assert!(t[1].xf.translation().to_point().distance(Point::new(10.0, 60.0)) < 1e-9, "{:?}", t[1].xf);
}
#[test]
fn type_on_a_scaled_path_takes_the_scale() {
let d = open(
r##"<g transform="scale(2)"><path id="p" d="M10 100 L110 100" fill="none"/>
<text font-size="10"><textPath href="#p">Curve</textPath></text></g>"##,
);
let t = only_text(&d);
assert!(close(t.first_style().size, 20.0, 1e-9), "{}", t.first_style().size);
assert!(close(t.xf.determinant(), 1.0, 1e-6), "{:?}", t.xf);
let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) };
let b = t.xf.transform_rect_bbox(path.bounds().unwrap());
assert!(close(b.x0, 20.0, 1e-9) && close(b.x1, 220.0, 1e-9) && close(b.y0, 200.0, 1e-9), "{b:?}");
}
#[test]
fn folded_sizes_stay_in_the_character_panels_range() {
// Past 1296 pt (or under 0.1 pt) the rest of the scale stays the transform.
let d = open(
r#"<g transform="scale(20)"><text y="10" font-size="100">Big</text></g>
<g transform="scale(0.001)"><text y="10" font-size="7">Small</text></g>
<text y="10" font-size="2000">Huge</text>"#,
);
let t = texts(&d);
let size = |i: usize| t[i].first_style().size;
assert!(close(size(0), 1296.0, 1e-9) && close(size(0) * linear(t[0])[0], 2000.0, 1e-9), "{:?}", t[0].xf);
assert!(close(size(1), 0.1, 1e-9) && close(size(1) * linear(t[1])[0], 0.007, 1e-9), "{:?}", t[1].xf);
assert!(close(size(2), 2000.0, 1e-9) && close4(linear(t[2]), [1.0, 0.0, 0.0, 1.0]), "unscaled type keeps its size");
}
#[test]
fn gradients_on_scaled_text_stay_in_place() {
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></defs>
<g transform="scale(2)"><text x="20" y="50" font-size="20" fill="url(#user)">Gradient</text></g>"##,
);
let t = only_text(&d);
assert!(close(t.first_style().size, 40.0, 1e-9));
let Paint::Gradient(g) = &t.runs[0].style.fill else { panic!("{:?}", t.runs[0].style.fill) };
let geom = g.geom.unwrap();
let (start, end) = (t.xf * geom.start, t.xf * geom.end);
assert!(start.distance(Point::new(40.0, 0.0)) < 1e-6 && end.distance(Point::new(240.0, 0.0)) < 1e-6, "{start:?} {end:?}");
}
/// The start of a type-on-a-path object's path and a point a little way along it, in document
/// space.
fn path_start(t: &TextObject) -> (Point, Point) {
use vectorcraft_geom::kurbo::{ParamCurve, PathEl};
let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) };
let mut bp = path.to_bezpath();
bp.apply_affine(t.xf);
let PathEl::MoveTo(p0) = bp.elements()[0] else { panic!("{bp:?}") };
let next = bp.segments().next().unwrap().eval(0.05);
(p0, next)
}
#[test]
fn text_path_follows_basic_shapes_as_their_equivalent_paths() {
let (d, w) = import_with_report(&svg(r##"<circle id="c" cx="150" cy="150" r="100"/>
<ellipse id="e" cx="150" cy="150" rx="80" ry="40" transform="translate(10 0)"/>
<rect id="r" x="20" y="30" width="100" height="60" rx="10"/>
<rect id="s" x="20" y="30" width="100" height="60"/>
<line id="l" x1="10" y1="200" x2="210" y2="200"/>
<polyline id="pl" points="10,10 110,10 110,60"/>
<polygon id="pg" points="10 10, 60 10, 35 50"/>
<text font-size="10"><textPath xlink:href="#c">TEXT ON A CIRCLE</textPath></text>
<text font-size="10"><textPath href="#e">Ellipse</textPath></text>
<text font-size="10"><textPath href="#r">Rounded</textPath></text>
<text font-size="10"><textPath href="#s">Square</textPath></text>
<text font-size="10"><textPath href="#l">Line</textPath></text>
<text font-size="10"><textPath href="#pl">Polyline</textPath></text>
<text font-size="10"><textPath href="#pg">Polygon</textPath></text>"##))
.unwrap();
assert!(!w.iter().any(|w| w.contains("textPath")), "{w:?}");
let t = texts(&d);
assert_eq!(t.len(), 7);
assert!(t.iter().all(|t| matches!(t.kind, TextKind::OnPath { .. })), "{:?}", t.iter().map(|t| &t.kind).collect::<Vec<_>>());
let near = |a: Point, b: Point| (a - b).hypot() < 1e-6;
// Circle: starts at 3 o'clock and runs clockwise (down first, y pointing down), around the circle.
let (p0, next) = path_start(t[0]);
assert!(near(p0, Point::new(250.0, 150.0)) && next.y > p0.y && next.x < p0.x, "{p0:?} {next:?}");
let TextKind::OnPath { path, .. } = &t[0].kind else { panic!("not on a path") };
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
assert!(close(b.x0, 50.0, 1e-6) && close(b.y0, 50.0, 1e-6) && close(b.x1, 250.0, 1e-6) && close(b.y1, 250.0, 1e-6), "{b:?}");
assert!(path.is_closed());
// Ellipse, with the shape's own transform.
let (p0, next) = path_start(t[1]);
assert!(near(p0, Point::new(240.0, 150.0)) && next.y > p0.y, "{p0:?} {next:?}");
// Rounded rect: after the top-left corner, along the top edge to the right.
let (p0, next) = path_start(t[2]);
assert!(near(p0, Point::new(30.0, 30.0)) && next.x > p0.x && close(next.y, 30.0, 1e-9), "{p0:?} {next:?}");
let (p0, next) = path_start(t[3]);
assert!(near(p0, Point::new(20.0, 30.0)) && next.x > p0.x, "{p0:?} {next:?}");
// Line, polyline and polygon follow their points.
assert!(near(path_start(t[4]).0, Point::new(10.0, 200.0)));
assert!(near(path_start(t[5]).0, Point::new(10.0, 10.0)));
let TextKind::OnPath { path, .. } = &t[6].kind else { panic!("not on a path") };
assert!(path.is_closed() && near(path_start(t[6]).0, Point::new(10.0, 10.0)));
assert_eq!(t[0].plain_text(), "TEXT ON A CIRCLE");
}
#[test]
fn text_path_to_an_empty_shape_still_falls_back_to_point_type() {
let (d, w) =
import_with_report(&svg(r##"<circle id="c" cx="5" cy="5" r="0"/><text x="5" y="10"><textPath href="#c">Zero</textPath></text>"##)).unwrap();
assert!(matches!(only_text(&d).kind, TextKind::Point));
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
}
/// #1557: the `font` shorthand sets the type's size, weight and style.
#[test]
fn the_font_shorthand_styles_type() {
let d = open(r#"<text x="10" y="40" style="font: italic bold 30px sans-serif">Deco</text>"#);
let t = only_text(&d);
let st = &t.runs[0].style;
assert!(close(st.size * t.xf.determinant().abs().sqrt(), 30.0, 1e-6), "{}", st.size);
let style = st.font_style.to_ascii_lowercase();
assert!(style.contains("bold") && style.contains("italic"), "{style}");
}
/// Inkscape's flowed text (`<flowRoot>`) becomes area type in its region's rectangle, a paragraph
/// per `<flowPara>`; another kind of region is left out with a warning, not silently.
#[test]
fn flowed_text_becomes_area_type() {
let (d, w) = import_with_report(&svg(
r#"<flowRoot style="font-size:16px;font-family:sans-serif"><flowRegion><rect x="20" y="30" width="100" height="160"/></flowRegion><flowPara>This is flowed text</flowPara><flowPara>Second <flowSpan>one</flowSpan></flowPara></flowRoot>
<flowRoot><flowRegion><path d="M0 0 L10 0 L5 10 Z"/></flowRegion><flowPara>Lost</flowPara></flowRoot>"#,
))
.unwrap();
let t = only_text(&d);
assert_eq!(t.plain_text(), "This is flowed text\nSecond one");
let TextKind::Area { frame } = &t.kind else { panic!("{:?}", t.kind) };
let b = t.xf.transform_rect_bbox(frame.bounds().unwrap());
assert!(close(b.x0, 20.0, 1e-6) && close(b.y0, 30.0, 1e-6) && close(b.x1, 120.0, 1e-6) && close(b.y1, 190.0, 1e-6), "{b:?}");
assert!(close(t.runs[0].style.size * t.xf.determinant().abs().sqrt(), 16.0, 1e-6));
assert!(w.iter().any(|w| w.contains("flowRoot")), "{w:?}");
}