Files
vectorcraft/crates/svg/tests/text_layout.rs
T
zakariayacineandClaude Fable 5.1 16af3cc3fa Type: Digits — Default, Arabic, Hindi and Farsi numeral systems
Adds the Character panel menu's Digits option (with the Middle Eastern /
Indic options, beside Paragraph Direction in the Paragraph panel) as a
character attribute: CharStyle::digits, saved only when set, with
text.setFormat {digits} and text.setRangeStyle {id, start, end, digits}
("default" | "arabic" | "hindi" | "farsi"; a bad value is an error).

Shaping draws every digit of the Western, Arabic-Indic (U+0660–U+0669)
and Eastern Arabic-Indic (U+06F0–U+06F9) sets in the chosen system,
whatever was typed, before font fallback, so a font without the
Arabic-Indic digits gets a fallback for them. The text, its clusters,
the caret, selection and search stay as typed, as with All Caps; SVG
export writes the digits as drawn.

Catalog rows for Digits, Arabic, Hindi and Farsi in the ten translated
languages. Docs: docs/mcp.md (Digits), type-parity.md (digit types
done; Character Direction, kashidas, composers and the split caret
still missing), target-app-parity.md and gaps.md.

Behaviour from the public documentation (a character-level option with
the values Arabic, Hindi and Farsi in the Character panel menu):
https://helpx.adobe.com/indesign/desktop/language-and-proofing/arabic-and-hebrew/choose-digit-types.html

Tests: doc (the mapping and serialization), text (glyphs of "page 3" in
Hindi/Farsi equal the typed Arabic-Indic digits' on the typed clusters;
skips without a font covering the three sets), engine (setFormat,
setRangeStyle, undo, error), svg (digits written as drawn), ui-egui
(the menu shows with the Indic options).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 22:18:01 +07:00

267 lines
14 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.
//! 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);
}
}