//! 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 { area_doc_justified(runs, w, justify, vectorcraft_doc::JustificationOptions::default()) } /// [`area_doc`] with the paragraph's Justification options `j`. fn area_doc_justified(runs: Vec, w: f64, justify: Justify, j: vectorcraft_doc::JustificationOptions) -> 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; t.para.justification = j; 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")); } /// 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:"palt" 1"), "{}", tags[1]); assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } /// Character Direction: live text writes each run set Left to Right as a bidi embedding /// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. #[test] fn character_direction_is_written_on_its_runs() { use vectorcraft_doc::ParaDirection; let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) }; let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left); let svg = export(&d, &ExportOptions::default()); let tags = tspans(&svg); assert_eq!(tags.len(), 3, "{svg}"); assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}"); assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]); // Type on a path: on the run's `` in the `` too. let mut on_path = TextObject::point(Point::ZERO, "", style(12.0)); on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)]; let mut line = BezPath::new(); line.move_to((20.0, 100.0)); line.line_to((280.0, 100.0)); on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; let mut d = Document::new(300.0, 200.0); let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let svg = export(&d, &ExportOptions::default()); let tags = tspans(&svg); assert!(svg.contains("= 3, "{svg}"); } /// The only type in `d`. fn the_text(d: &Document) -> TextObject { d.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap() } /// A drawn character: its text, where its first glyph is drawn, and the glyph's horizontal scale /// (font units to user space). type Drawn = (String, Point, f64); /// Each character a usvg draws from `svg` (with the bundled font and the font files of `faces`) /// and how it draws the character's first glyph, in SVG user space and text order; spaces and /// tabs are left out. fn viewer_glyph_positions(svg: &str, faces: &[Arc]) -> 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()); for f in faces { db.load_font_data(f.file_data().to_vec()); } 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) => { for g in t.layouted().iter().flat_map(|span| &span.positioned_glyphs) { if g.text.trim().is_empty() { continue; } let m = t.abs_transform().pre_concat(g.transform()); out.push((g.text.clone(), Point::new(m.tx as f64, m.ty as f64), m.sx as f64)); } } _ => {} } } } let mut out = vec![]; walk(tree.root(), &mut out); out } /// Each character the canvas draws of the only type in `d` and how it draws the character's /// first glyph, in document space and text order (spaces and tabs left out), with the faces /// it draws them in. fn canvas_glyph_positions(d: &Document) -> (Vec, Vec>) { let t = the_text(d); let db = vectorcraft_text::FontDb::global(); let lay = vectorcraft_text::layout(db, &t); let plain = t.plain_text(); let mut out: Vec = vec![]; let mut faces: Vec> = vec![]; let mut last = None; for (i, g) in lay.glyphs.iter().enumerate() { let text = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }); if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) { continue; } // A list marker's glyphs (length 0) start no cluster. last = (g.len > 0).then_some(g.byte); if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) { faces.push(f); } let m = t.xf * g.xf; out.push((text.to_string(), m * Point::ZERO, m.as_coeffs()[0])); } (out, faces) } /// Asserts that a viewer draws every character of `d`'s live SVG text where the canvas draws it, /// as wide as the canvas draws it. fn assert_drawn_in_place(d: &Document) { let svg = export(d, &ExportOptions::default()); let (canvas, faces) = canvas_glyph_positions(d); let viewer = viewer_glyph_positions(&svg, &faces); let chars = |v: &[Drawn]| v.iter().map(|(s, _, _)| s.as_str()).collect::(); assert_eq!(chars(&viewer), chars(&canvas), "{svg}"); for ((s, v, vs), (_, c, cs)) in viewer.iter().zip(&canvas) { assert!((v.x - c.x).abs() < 0.05 && (v.y - c.y).abs() < 0.05, "{s}: the viewer draws it at {v:?}, the canvas at {c:?}: {svg}"); assert!((vs / cs - 1.0).abs() < 0.002, "{s}: the viewer scales it by {vs}, the canvas by {cs}: {svg}"); } } /// Bullets: live text writes each list paragraph's bullet as its character before the paragraph's /// text, not as a hyphen, and a viewer draws the bullet and the text where the canvas draws them. #[test] fn bullets_are_written_as_their_characters_where_they_are_drawn() { use vectorcraft_doc::text::{Bullet, ParaList}; for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Square, "\u{25AA}")] { let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta", style(12.0)); t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) }; t.para.list = Some(ParaList::bulleted(bullet)); t.para.left_indent = 18.0; t.para.first_line_indent = -18.0; 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(); let svg = export(&d, &ExportOptions::default()); assert_eq!(svg.matches(character).count(), 2, "one bullet before each paragraph: {svg}"); assert!(svg.find(character) < svg.find("alpha") && !svg.contains(">-<") && !svg.contains("-alpha"), "{svg}"); if bullet == Bullet::Dot { assert_drawn_in_place(&d); } } // A raised and rotated first character: the bullet is written on the baseline, upright. let raised = CharStyle { baseline_shift: 4.0, rotation: 20.0, ..style(12.0) }; let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0)); t.runs = vec![run("R", raised), run("aised", style(12.0))]; t.para.list = Some(ParaList::bulleted(Bullet::Dot)); 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(); let svg = export(&d, &ExportOptions::default()); // Each `` start tag with the text after it. let pieces: Vec<(&str, &str)> = svg.split("')).collect(); let (tag, _) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); assert!(!tag.contains("dy=") && !tag.contains("rotate="), "{svg}"); assert!( pieces.iter().any(|(tag, text)| text.starts_with('R') && tag.contains("dy=") && tag.contains("rotate=")), "the R keeps its shift and rotation: {svg}" ); // Horizontally scaled type: a viewer stretches the bullet to the bullet's own width, not to the // gap after it. let mut t = TextObject::point(Point::new(20.0, 20.0), "Narrow", CharStyle { h_scale: 50.0, ..style(12.0) }); t.para.list = Some(ParaList::bulleted(Bullet::Dot)); t.para.left_indent = 18.0; t.para.first_line_indent = -18.0; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); let width = lay.glyphs[lay.markers[0].glyphs.start].advance; 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(); let svg = export(&d, &ExportOptions::default()); let pieces: Vec<(&str, &str)> = svg.split("')).collect(); let (tag, text) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); let length: f64 = tag.split("textLength=\"").nth(1).and_then(|r| r.split('"').next()).and_then(|v| v.parse().ok()).expect("a textLength"); assert!(text.starts_with("\u{2022}<") && (length - width).abs() < 0.01, "the bullet alone, {width} wide: {svg}"); // Underlined text: the underline is the text's, not the bullet's. let mut t = TextObject::point(Point::new(20.0, 20.0), "Underlined", CharStyle { underline: true, ..style(12.0) }); t.para.list = Some(ParaList::bulleted(Bullet::Dot)); 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(); let svg = export(&d, &ExportOptions::default()); let text_tag = svg.split("').next()).expect("a "); let pieces: Vec<(&str, &str)> = svg.split("')).collect(); let (bullet_tag, _) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); assert!(!text_tag.contains("text-decoration") && !bullet_tag.contains("text-decoration"), "{svg}"); assert!(pieces.iter().any(|(tag, text)| text.starts_with("Underlined") && tag.contains("text-decoration=\"underline\"")), "{svg}"); // Type on a path with a bullet is written as outlines, with a warning: a `` holds the // characters of the text only. for list in [None, Some(ParaList::bulleted(Bullet::Dot))] { let mut line = BezPath::new(); line.move_to((0.0, 50.0)); line.line_to((250.0, 50.0)); let mut t = TextObject::point(Point::new(20.0, 20.0), "Path", style(12.0)); t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; t.para.list = list; 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(); let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None); let warned = out.warnings.iter().any(|w| w.contains("bullets")); assert_eq!((out.svg.contains("").unwrap()].to_string() }; let justified = area_doc(vec![run("aa bb cc dd ee ff", style(12.0))], 60.0, Justify::JustifyLeft); let mut composed = area_doc(vec![run("Latin words only", style(12.0))], 200.0, Justify::Center); let id = composed.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = composed.node_mut(id).map(|n| &mut n.kind) { t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; } let open = |x: &str, anchor: &str| { format!( r##""## ) }; let words = [("aa ", "0", "12"), ("bb ", "15.636", "12"), ("cc ", "32.448", "12"), ("dd", "46.68", "12"), ("ee ", "0", "26.4"), ("ff", "14.304", "26.4")]; let words: String = words.iter().map(|(w, x, y)| format!(r#"{w}"#)).collect(); assert_eq!(text(&justified), format!("{}{words}", open("0", ""))); let centered = format!(r#"{}Latin words only"#, open("100", r#" text-anchor="middle""#)); assert_eq!(text(&composed), centered); } /// Type scaled horizontally on a justified line is stretched to its own width, not to the /// justification space after it, nor to the font's kerning against the letter after it when /// the letters are spread (AV is a kerned pair). #[test] fn justified_lines_keep_scaled_type_at_its_width() { let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; for justify in [Justify::JustifyLeft, Justify::JustifyAll] { assert_drawn_in_place(&area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", wide.clone())], 120.0, justify)); assert_drawn_in_place(&area_doc(vec![run("abcdefgh", wide.clone())], 200.0, justify)); } assert_drawn_in_place(&area_doc(vec![run("AVAVAVAV", wide)], 200.0, Justify::JustifyAll)); } /// On lines that aren't spread, the spacing a reader applies itself (tracking, manual kerning, /// horizontal scale, baseline shift) puts each character where the canvas draws it. #[test] fn ragged_lines_keep_their_character_spacing() { let tracked = CharStyle { tracking: 80.0, ..style(12.0) }; let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) }; let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) }; for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] { let mut runs = vec![run("plain ", style(12.0)), run("tracked ", tracked.clone()), run("kerned ", kerned.clone()), run("up ", shifted.clone())]; // usvg anchors a centered or right-aligned line by its width before `textLength` stretches it. if matches!(justify, Justify::Left | Justify::JustifyLeft) { runs.push(run("wide ", wide.clone())); } runs.push(run("end", style(12.0))); assert_drawn_in_place(&area_doc(runs, 280.0, justify)); } } /// Type scaled horizontally before a tab is stretched to its own width, not to the tab's gap. #[test] fn scaled_type_before_a_tab_keeps_its_width() { for h_scale in [50.0, 150.0] { let scaled = CharStyle { h_scale, ..style(12.0) }; assert_drawn_in_place(&area_doc(vec![run("ab\tcd ef\tgh", scaled)], 280.0, Justify::Left)); } } /// Justified Japanese lines spread between their characters (#344); live text keeps the gaps. #[test] fn justified_japanese_lines_keep_the_gaps_between_their_characters() { let text = "日本語の文章は文字と文字の間で両端を揃えます。この段落は三行になります。"; for justify in [Justify::JustifyLeft, Justify::JustifyAll] { assert_drawn_in_place(&area_doc(vec![run(text, style(12.0))], 100.0, justify)); } } /// Area type that Shrink Text to Fit scales down to fit its frame is written at the size the /// canvas draws it at: sizes, tracking, manual kerning and baseline shifts, on ragged, anchored /// and justified lines. #[test] fn shrunk_area_type_is_written_at_the_size_it_draws_at() { let tracked = CharStyle { tracking: 80.0, ..style(12.0) }; let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) }; let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) }; for justify in [Justify::Left, Justify::Center, Justify::JustifyLeft, Justify::JustifyAll] { // Tracked and kerned letters are inside words: usvg also spaces a line's last letter, which // moves an anchored line ending in one. let mut runs = vec![ run("plain words ", style(12.0)), run("trac", tracked.clone()), run("ked ", style(12.0)), run("ker", kerned.clone()), run("ned ", style(12.0)), run("up ", shifted.clone()), run("big ", style(18.0)), ]; // usvg anchors a centered line by its width before `textLength` stretches it. if justify != Justify::Center { runs.push(run("wide ", wide.clone())); } runs.push(run("and more words at the end", style(12.0))); let mut d = area_doc(runs, 90.0, justify); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 40.0)) }; t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &the_text(&d)); assert!(!lay.overflow && lay.fit_scale < 0.8, "{justify:?}: the text is shrunk to fit ({})", lay.fit_scale); assert_drawn_in_place(&d); // Fewer `` Elements writes the shrunk size too. let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() }); let size = attr(&fewer[fewer.find(" 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); } }