mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Every laid-out line of type is written at its own position (wraps, indents, tabs, hyphenation and all caps as on the canvas): a positioned tspan per style run and, on justified lines, per word. Centred and right-aligned lines are anchored at their centre or right end, so they stay aligned in a viewer whose font differs. Leading comes from the layout's baselines, baseline shift is written as dy, horizontal scale as textLength, vertical scale as the size, tracking and manual kerning as letter-spacing. The fewerTspans option (engine and dialog) writes one positioned tspan per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
9.6 KiB
Rust
189 lines
9.6 KiB
Rust
//! 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 }
|
||
}
|
||
|
||
/// 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 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"));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|
||
}
|
||
}
|