//! Laid-out type in SVG: every line of area type is its own positioned ``, 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, 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 `` start tags. fn tspans(svg: &str) -> Vec<&str> { svg.match_indices("').unwrap()]).collect() } fn attr(tag: &str, name: &str) -> Option { 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 = 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(¢red)); // 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")); } #[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 { 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) { 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::() > 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 has none). assert!(svg.contains("text-anchor=\"middle\"") && svg.contains("text-anchor=\"end\""), "{svg}"); assert!(!svg.contains(" = 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); } }