vectorcraft/crates/svg/tests/text_layout.rs

267 lines
14 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.

//! Laid-out type in SVG: every line of area type is its own positioned `<tspan>`, rich text keeps
//! its leading, baseline shift, scaling and kerning, and a renderer with the same font draws the
//! export where the canvas draws the text.
// 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 vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
fn style(size: f64) -> CharStyle {
CharStyle { size, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }
}
/// Area type `w` wide at (20, 20) in a 300 × 200 document.
fn area_doc(runs: Vec<TextRun>, w: f64, justify: Justify) -> Document {
let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0));
t.runs = runs;
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, 160.0)) };
t.para.justify = justify;
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn run(text: &str, st: CharStyle) -> TextRun {
TextRun { text: text.into(), style: st, inline: None }
}
/// The `<tspan …>` start tags.
fn tspans(svg: &str) -> Vec<&str> {
svg.match_indices("<tspan").map(|(i, _)| &svg[i..i + svg[i..].find('>').unwrap()]).collect()
}
fn attr(tag: &str, name: &str) -> Option<f64> {
let k = format!(" {name}=\"");
let i = tag.find(&k)? + k.len();
tag[i..i + tag[i..].find('"')?].parse().ok()
}
#[test]
fn wrapped_lines_get_one_tspan_each() {
let d = area_doc(vec![run("alpha beta gamma delta epsilon zeta", style(12.0))], 70.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
let ys: Vec<f64> = tags.iter().map(|t| attr(t, "y").expect("each line is placed")).collect();
assert!(ys.len() >= 3, "the text wraps: {svg}");
assert!(ys.windows(2).all(|w| w[1] > w[0]), "baselines go down: {ys:?}");
// Auto leading: 120 % of the size between baselines.
assert!((ys[1] - ys[0] - 14.4).abs() < 0.01, "{ys:?}");
assert!(tags.iter().all(|t| attr(t, "x") == Some(0.0)), "left aligned at the frame's edge: {svg}");
// Reading it back gives a line per baseline, starting at the first baseline.
let back = vectorcraft_svg::import(&svg).unwrap();
let text = back.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.clone()) } else { None }).unwrap();
assert_eq!(text.plain_text().lines().count(), ys.len(), "{:?}", text.plain_text());
assert!((text.xf.translation().y - (20.0 + ys[0])).abs() < 0.01);
// Centred lines are anchored at the frame's centre, so they stay centred in any font.
let svg = export(&area_doc(vec![run("alpha beta gamma delta", style(12.0))], 70.0, Justify::Center), &ExportOptions::default());
assert!(svg.contains("text-anchor=\"middle\""), "{svg}");
assert!(tspans(&svg).iter().all(|t| (attr(t, "x").unwrap() - 35.0).abs() < 0.01), "{svg}");
// Left-aligned (and justified) lines are placed absolutely.
assert!(!export(&d, &ExportOptions::default()).contains("text-anchor"));
}
#[test]
fn vertically_aligned_area_type_exports_where_it_is_drawn() {
let top = area_doc(vec![run("alpha beta", style(12.0))], 200.0, Justify::Left);
let mut centred = top.clone();
let id = centred.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = centred.node_mut(id).map(|n| &mut n.kind) {
t.area.vertical_align = vectorcraft_doc::VerticalAlign::Center;
}
let y = |d: &Document| attr(tspans(&export(d, &ExportOptions::default()))[0], "y").unwrap();
let (yt, yc) = (y(&top), y(&centred));
// The 160 pt frame holds one 12 pt line: centring moves it down about (160 - 14.4) / 2.
assert!(yc - yt > 60.0 && yc - yt < 80.0, "{yt} → {yc}");
}
#[test]
fn rich_text_writes_spacing_shift_and_scale() {
let shifted = CharStyle { baseline_shift: 4.0, ..style(12.0) };
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
let kerned = CharStyle { tracking: 50.0, kerning: Some(-20.0), ..style(12.0) };
let tall = CharStyle { v_scale: 200.0, h_scale: 200.0, leading: Some(30.0), ..style(12.0) };
let d = area_doc(
vec![run("Base ", style(12.0)), run("up ", shifted), run("wide ", wide), run("kern\n", kerned), run("tall", tall)],
280.0,
Justify::Left,
);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 5, "{svg}");
assert_eq!(attr(tags[1], "dy"), Some(-4.0), "baseline shift: {}", tags[1]);
assert!(attr(tags[2], "textLength").is_some() && tags[2].contains("lengthAdjust=\"spacingAndGlyphs\""), "{}", tags[2]);
assert!(tags[3].contains("letter-spacing=\"0.36\"") && tags[3].contains("font-kerning:none"), "tracking + kerning: {}", tags[3]);
assert!(tags[4].contains("font-size=\"24\"") && attr(tags[4], "textLength").is_none(), "uniform scale is the size: {}", tags[4]);
assert!((attr(tags[4], "y").unwrap() - attr(tags[0], "y").unwrap() - 30.0).abs() < 0.01, "leading");
// Segments on one baseline share its y; the shifted one is placed with dy.
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
let proportional = CharStyle { proportional_metrics: true, ..style(12.0) };
let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) };
let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 3, "{svg}");
assert!(!tags[0].contains("palt"), "{}", tags[0]);
assert!(tags[1].contains("font-feature-settings:&quot;palt&quot; 1"), "{}", tags[1]);
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
/// Digits are written as drawn: the style's numeral system, not the digits that were typed (as
/// All Caps writes capitals).
#[test]
fn digits_are_written_as_drawn() {
use vectorcraft_doc::Digits;
let hindi = CharStyle { digits: Digits::Hindi, ..style(12.0) };
let farsi = CharStyle { digits: Digits::Farsi, ..style(12.0) };
let d = area_doc(vec![run("page 3, ", style(12.0)), run("page 4, ", hindi), run("page ٥", farsi)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let text = svg.as_str();
assert!(text.contains("page 3,"), "typed Western digits stay in Default: {svg}");
assert!(text.contains("page ٤,"), "Hindi draws 4 as ٤: {svg}");
assert!(text.contains("page ۵"), "Farsi draws ٥ as ۵: {svg}");
assert!(!text.contains("page 4") && !text.contains("page ٥"), "{svg}");
}
#[test]
fn fewer_tspans_writes_one_per_line() {
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
let runs = vec![run("one ", style(12.0)), run("two", bold), run(" three\nfour", style(12.0))];
let d = area_doc(runs, 280.0, Justify::Left);
let full = export(&d, &ExportOptions::default());
let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() });
let placed = |s: &str| tspans(s).iter().filter(|t| attr(t, "y").is_some()).count();
assert_eq!(placed(&full), 4, "{full}");
assert_eq!(placed(&fewer), 2, "one positioned tspan per line: {fewer}");
assert!(fewer.contains("font-weight=\"bold\""), "style changes stay: {fewer}");
assert!(fewer.len() < full.len());
}
#[test]
fn justified_lines_place_each_word() {
let d = area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", style(12.0))], 90.0, Justify::JustifyLeft);
let svg = export(&d, &ExportOptions::default());
let first_line_y = attr(tspans(&svg)[0], "y").unwrap();
let words = tspans(&svg).iter().filter(|t| attr(t, "y") == Some(first_line_y)).count();
assert!(words >= 3, "{svg}");
}
/// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG
/// user space.
fn viewer_outlines(svg: &str) -> Vec<BezPath> {
let mut db = usvg::fontdb::Database::new();
db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap());
let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() };
let tree = usvg::Tree::from_str(svg, &opt).unwrap();
fn walk(g: &usvg::Group, out: &mut Vec<BezPath>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => walk(g, out),
usvg::Node::Text(t) => walk(t.flattened(), out),
usvg::Node::Path(p) => {
let m = p.abs_transform();
let m = Affine::new([m.sx as f64, m.ky as f64, m.kx as f64, m.sy as f64, m.tx as f64, m.ty as f64]);
let mut bp = BezPath::new();
for s in p.data().segments() {
use usvg::tiny_skia_path::PathSegment as S;
let pt = |q: usvg::tiny_skia_path::Point| m * Point::new(q.x as f64, q.y as f64);
match s {
S::MoveTo(a) => bp.move_to(pt(a)),
S::LineTo(a) => bp.line_to(pt(a)),
S::QuadTo(a, b) => bp.quad_to(pt(a), pt(b)),
S::CubicTo(a, b, c) => bp.curve_to(pt(a), pt(b), pt(c)),
S::Close => bp.close_path(),
}
}
out.push(bp);
}
usvg::Node::Image(_) => {}
}
}
}
let mut out = vec![];
walk(tree.root(), &mut out);
out
}
#[test]
fn a_viewer_draws_the_text_where_the_canvas_does() {
// With a tab every line is placed absolutely; without one, centred and right-aligned lines are
// anchored, and a baseline shift mid-line is undone by the next run.
for (sep, shift) in [(" ", 0.0), (" ", 3.0)] {
let second = |fill: Color| CharStyle { fill: Paint::solid(fill), baseline_shift: shift, ..style(14.0) };
let runs = |second: CharStyle| {
vec![
run("The quick brown fox jumps over the lazy dog. ", style(14.0)),
run(&format!("Pack my box{sep}with"), second),
run(" five dozen jugs.", style(14.0)),
]
};
for justify in [Justify::Left, Justify::Center, Justify::Right] {
for fewer_tspans in [false, true] {
let o = ExportOptions { fewer_tspans, ..Default::default() };
let svg = export(&area_doc(runs(second(Color::rgb(0.8, 0.0, 0.0))), 200.0, justify), &o);
assert!(svg.contains("fill=\"#cc0000\""), "{svg}");
// The viewer's glyphs as black shapes, against the canvas drawing the text all black.
let mut viewer = Document::new(300.0, 200.0);
let l = viewer.layers[0].id;
let outlines = viewer_outlines(&svg);
assert!(outlines.iter().map(|b| b.elements().len()).sum::<usize>() > 200, "the viewer laid the text out");
for bp in outlines {
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let n = Node::path(viewer.alloc_id(), PathData::from_bezpath(&bp), ap);
viewer.insert(Some(l), usize::MAX, n).unwrap();
}
let canvas = area_doc(runs(second(Color::BLACK)), 200.0, justify);
assert_similar(&render_artboard(&canvas), &render_artboard(&viewer), 40.0, 0.01);
}
}
}
}
#[test]
fn paragraphs_aligned_differently_keep_their_alignment() {
let mut t = TextObject::point(Point::new(20.0, 20.0), "Left words here\nCentred line\nRight line", style(14.0));
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 160.0)) };
let para = |j| vectorcraft_doc::ParaStyle { justify: j, ..Default::default() };
t.set_paragraph_styles(vec![para(Justify::Left), para(Justify::Center), para(Justify::Right)]);
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
for fewer_tspans in [false, true] {
let svg = export(&d, &ExportOptions { fewer_tspans, ..Default::default() });
// Each anchored line carries its own anchor (the <text> has none).
assert!(svg.contains("text-anchor=\"middle\"") && svg.contains("text-anchor=\"end\""), "{svg}");
assert!(!svg.contains("<text text-anchor"), "{svg}");
// A viewer draws each line where the canvas does.
let mut viewer = Document::new(300.0, 200.0);
let vl = viewer.layers[0].id;
for bp in viewer_outlines(&svg) {
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let n = Node::path(viewer.alloc_id(), PathData::from_bezpath(&bp), ap);
viewer.insert(Some(vl), usize::MAX, n).unwrap();
}
assert_similar(&render_artboard(&d), &render_artboard(&viewer), 40.0, 0.01);
// Read back: one paragraph per line, each with its alignment, drawn where it was.
let back = vectorcraft_svg::import(&svg).unwrap();
let text =
back.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.clone()) } else { None }).unwrap();
let js: Vec<Justify> = text.paragraph_styles().iter().map(|p| p.justify).collect();
assert_eq!(js, [Justify::Left, Justify::Center, Justify::Right], "{svg}");
assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.01);
}
}