mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
392 lines
15 KiB
Rust
392 lines
15 KiB
Rust
use super::*;
|
|
use kurbo::{Affine, Shape};
|
|
use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun};
|
|
use vectorcraft_geom::PathData;
|
|
|
|
fn db() -> &'static FontDb {
|
|
FontDb::global()
|
|
}
|
|
|
|
fn style(size: f64) -> CharStyle {
|
|
CharStyle { size, ..CharStyle::default() }
|
|
}
|
|
|
|
fn serif(size: f64) -> CharStyle {
|
|
CharStyle { font_family: "Source Serif 4".into(), ..style(size) }
|
|
}
|
|
|
|
fn point(text: &str, st: CharStyle) -> TextObject {
|
|
TextObject::point(Point::ZERO, text, st)
|
|
}
|
|
|
|
fn area(text: &str, st: CharStyle, frame: Rect, justify: Justify) -> TextObject {
|
|
let mut t = point(text, st);
|
|
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
|
t.xf = Affine::IDENTITY;
|
|
t.para.justify = justify;
|
|
t
|
|
}
|
|
|
|
fn width(t: &TextObject) -> f64 {
|
|
let l = layout(db(), t);
|
|
l.glyphs.iter().map(|g| g.advance).sum()
|
|
}
|
|
|
|
const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
|
|
|
|
#[test]
|
|
fn bundled_families_and_styles() {
|
|
let f = db().families();
|
|
for fam in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
|
|
assert!(f.iter().any(|x| x == fam), "{fam} missing from {f:?}");
|
|
}
|
|
let s = db().styles("Source Sans 3");
|
|
assert_eq!(s.first().map(String::as_str), Some("Regular"), "{s:?}");
|
|
assert!(s.iter().any(|x| x == "Bold") && s.iter().any(|x| x == "Italic") && s.iter().any(|x| x == "Semibold"), "{s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn style_lookup_and_fallback() {
|
|
let f = db().face("Inter", "Semibold");
|
|
assert_eq!((f.family.as_str(), f.style.as_str()), ("Inter", "SemiBold"));
|
|
let f = db().face("inter", "Bold");
|
|
assert_eq!(f.style, "SemiBold", "closest weight");
|
|
let f = db().face("No Such Font", "Regular");
|
|
assert_eq!((f.family.as_str(), f.style.as_str()), ("Source Sans 3", "Regular"));
|
|
let f = db().face("No Such Font", "Bold");
|
|
assert_eq!(f.style, "Bold");
|
|
assert_eq!(db().face("Source Sans 3", "Italic").style, "Italic");
|
|
}
|
|
|
|
#[test]
|
|
fn add_font_rejects_garbage_and_duplicates() {
|
|
assert_eq!(db().add_font(vec![1, 2, 3]), 0);
|
|
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec();
|
|
assert_eq!(db().add_font(bytes), 0, "already bundled");
|
|
}
|
|
|
|
#[test]
|
|
fn glyph_count_and_advances() {
|
|
let l = layout(db(), &point("Hello", style(24.0)));
|
|
assert_eq!(l.glyphs.len(), 5);
|
|
assert_eq!(l.lines.len(), 1);
|
|
for (i, g) in l.glyphs.iter().enumerate() {
|
|
assert!(g.advance > 0.0);
|
|
assert_eq!(g.byte, i);
|
|
assert!(!g.outline.elements().is_empty());
|
|
}
|
|
// Glyphs sit on the baseline (y = 0), ascenders above it (y < 0, y-down).
|
|
let h = l.glyphs[0].outline.bounding_box();
|
|
assert!(h.y0 < -10.0 && h.y1.abs() < 1.0, "{h:?}");
|
|
assert!(l.bounds.width() > 30.0);
|
|
}
|
|
|
|
#[test]
|
|
fn ligatures_and_kerning() {
|
|
// "fi" forms a ligature in Source Serif 4: one glyph covering two bytes.
|
|
let l = layout(db(), &point("fi", serif(20.0)));
|
|
assert_eq!(l.glyphs.len(), 1);
|
|
assert_eq!(l.glyphs[0].len, 2);
|
|
// Metrics kerning tightens "AV"; manual kerning 0 disables it.
|
|
let auto = width(&point("AV", style(100.0)));
|
|
let off = width(&point("AV", CharStyle { kerning: Some(0.0), ..style(100.0) }));
|
|
assert!(auto < off - 1.0, "auto {auto} off {off}");
|
|
}
|
|
|
|
#[test]
|
|
fn point_text_lines_and_auto_leading() {
|
|
let l = layout(db(), &point("one\ntwo\n", style(10.0)));
|
|
assert_eq!(l.lines.len(), 3);
|
|
assert_eq!(l.lines[0].baseline, 0.0);
|
|
assert!((l.lines[1].baseline - 12.0).abs() < 1e-9);
|
|
assert!((l.lines[2].baseline - 24.0).abs() < 1e-9);
|
|
assert_eq!((l.lines[0].start, l.lines[0].end), (0, 3));
|
|
assert_eq!((l.lines[1].start, l.lines[1].end), (4, 7));
|
|
assert_eq!((l.lines[2].start, l.lines[2].end), (8, 8));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_leading() {
|
|
let l = layout(db(), &point("a\nb", CharStyle { leading: Some(30.0), ..style(10.0) }));
|
|
assert!((l.lines[1].baseline - 30.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn point_alignment() {
|
|
let mut t = point("Centered", style(20.0));
|
|
let w = width(&t);
|
|
t.para.justify = Justify::Center;
|
|
let l = layout(db(), &t);
|
|
assert!((l.lines[0].x0 + w / 2.0).abs() < 1e-6 && (l.lines[0].x1 - w / 2.0).abs() < 1e-6);
|
|
t.para.justify = Justify::Right;
|
|
let l = layout(db(), &t);
|
|
assert!(l.lines[0].x1.abs() < 1e-6);
|
|
assert!(l.glyphs[0].origin.x < -w + 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn tracking_and_scale() {
|
|
let base = width(&point("abcd", style(10.0)));
|
|
let tracked = width(&point("abcd", CharStyle { tracking: 100.0, ..style(10.0) }));
|
|
assert!((tracked - base - 4.0 * 1.0).abs() < 1e-6, "{base} {tracked}");
|
|
let wide = width(&point("abcd", CharStyle { h_scale: 200.0, ..style(10.0) }));
|
|
assert!((wide - 2.0 * base).abs() < 0.05);
|
|
let l = layout(db(), &point("x", CharStyle { baseline_shift: 5.0, ..style(10.0) }));
|
|
let b = l.glyphs[0].outline.bounding_box();
|
|
assert!(b.y1 < -4.0, "shifted up: {b:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn all_caps() {
|
|
let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) }));
|
|
let b = width(&point("ABC", style(10.0)));
|
|
assert!((a - b).abs() < 1e-9);
|
|
let l = layout(db(), &point("\u{df}", CharStyle { all_caps: true, ..style(10.0) }));
|
|
assert_eq!(l.glyphs.len(), 2, "ß -> SS");
|
|
}
|
|
|
|
#[test]
|
|
fn area_line_breaking_within_frame() {
|
|
let frame = Rect::new(10.0, 10.0, 110.0, 400.0);
|
|
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Left));
|
|
assert!(l.lines.len() > 3);
|
|
assert!(!l.overflow);
|
|
for line in &l.lines {
|
|
assert!(line.x0 >= frame.x0 - 1e-6 && line.x1 <= frame.x1 + 1e-6, "{line:?}");
|
|
}
|
|
// Lines tile the text.
|
|
for w in l.lines.windows(2) {
|
|
assert_eq!(w[0].end, w[1].start);
|
|
}
|
|
assert_eq!(l.lines.last().unwrap().end, LOREM.len());
|
|
// First baseline = frame top + ascent; subsequent lines advance by the leading.
|
|
assert!((l.lines[0].baseline - (frame.y0 + l.lines[0].ascent)).abs() < 1e-6);
|
|
assert!((l.lines[1].baseline - l.lines[0].baseline - 14.4).abs() < 1e-6);
|
|
// Lines start at word boundaries.
|
|
for line in &l.lines[1..] {
|
|
assert_eq!(&LOREM[line.start - 1..line.start], " ");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn area_overflow() {
|
|
let small = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left));
|
|
assert!(small.overflow);
|
|
assert!(!small.lines.is_empty() && small.lines.last().unwrap().end < LOREM.len());
|
|
let big = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 1000.0, 400.0), Justify::Left));
|
|
assert!(!big.overflow);
|
|
assert_eq!(big.glyphs.len(), LOREM.chars().count());
|
|
}
|
|
|
|
#[test]
|
|
fn area_alignment_and_justify() {
|
|
let frame = Rect::new(0.0, 0.0, 150.0, 400.0);
|
|
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Right));
|
|
for line in &l.lines {
|
|
assert!((line.x1 - frame.x1).abs() < 1e-6);
|
|
}
|
|
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::JustifyLeft));
|
|
let n = l.lines.len();
|
|
for line in &l.lines[..n - 1] {
|
|
assert!((line.x0 - frame.x0).abs() < 1e-6 && (line.x1 - frame.x1).abs() < 1e-6, "{line:?}");
|
|
}
|
|
assert!(l.lines[n - 1].x1 < frame.x1 - 1.0, "last line not justified");
|
|
let l = layout(db(), &area("Wide", style(12.0), frame, Justify::JustifyAll));
|
|
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn indents_and_paragraph_spacing() {
|
|
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
|
|
let mut t = area("para one\npara two", style(10.0), frame, Justify::Left);
|
|
t.para.left_indent = 10.0;
|
|
t.para.first_line_indent = 5.0;
|
|
t.para.space_before = 6.0;
|
|
let l = layout(db(), &t);
|
|
assert!((l.lines[0].x0 - 15.0).abs() < 1e-9);
|
|
assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn non_rect_frame_narrows_lines() {
|
|
// Triangle pointing up: lines get wider towards the bottom.
|
|
let tri = PathData::from_bezpath(&{
|
|
let mut p = BezPath::new();
|
|
p.move_to((100.0, 0.0));
|
|
p.line_to((200.0, 200.0));
|
|
p.line_to((0.0, 200.0));
|
|
p.close_path();
|
|
p
|
|
});
|
|
let mut t = point(&LOREM.repeat(2), style(10.0));
|
|
t.kind = TextKind::Area { frame: tri };
|
|
let l = layout(db(), &t);
|
|
assert!(l.lines.len() > 5);
|
|
let w0 = l.lines[0].x1 - l.lines[0].x0;
|
|
let wl = l.lines[l.lines.len() - 2].x1 - l.lines[l.lines.len() - 2].x0;
|
|
assert!(w0 < wl, "{w0} {wl}");
|
|
for line in &l.lines {
|
|
let y = line.baseline - line.ascent;
|
|
let half = y / 2.0; // half-width of the triangle at y
|
|
assert!(line.x0 >= 100.0 - half - 1e-3 && line.x1 <= 100.0 + half + 1e-3, "{line:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn caret_mapping_round_trip() {
|
|
let l = layout(db(), &point("Hi there\nnext", style(20.0)));
|
|
let (top, bot) = caret_position(&l, 0);
|
|
assert!(top.x.abs() < 1e-9 && top.y < 0.0 && bot.y > 0.0);
|
|
let (end_top, _) = caret_position(&l, 8);
|
|
assert!((end_top.x - l.lines[0].x1).abs() < 1e-6);
|
|
for b in [0, 1, 3, 8, 9, 11, 13] {
|
|
let (t, bo) = caret_position(&l, b);
|
|
let mid = t.midpoint(bo);
|
|
assert_eq!(hit_byte(&l, mid + Vec2::new(0.1, 0.0)), b, "byte {b}");
|
|
}
|
|
// Second line.
|
|
let (t2, b2) = caret_position(&l, 10);
|
|
assert!(t2.y > 0.0 && b2.y > 24.0);
|
|
assert_eq!(hit_byte(&l, Point::new(1000.0, 24.0)), 13);
|
|
assert_eq!(hit_byte(&l, Point::new(-50.0, -100.0)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn caret_inside_ligature_and_empty_text() {
|
|
let l = layout(db(), &point("fi", serif(20.0)));
|
|
let (a, _) = caret_position(&l, 1);
|
|
let g = &l.glyphs[0];
|
|
assert!((a.x - g.advance / 2.0).abs() < 1e-6);
|
|
let e = layout(db(), &point("", style(20.0)));
|
|
assert_eq!(e.lines.len(), 1);
|
|
let (t, b) = caret_position(&e, 0);
|
|
assert!(t.y < 0.0 && b.y > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn on_path_placement() {
|
|
let mut line = BezPath::new();
|
|
line.move_to((0.0, 50.0));
|
|
line.line_to((500.0, 50.0));
|
|
let mut t = point("Path", style(20.0));
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1 };
|
|
let l = layout(db(), &t);
|
|
assert!(l.on_path && !l.overflow);
|
|
assert_eq!(l.glyphs.len(), 4);
|
|
assert!((l.glyphs[0].origin.x - 50.0).abs() < 1e-6);
|
|
for g in &l.glyphs {
|
|
assert!((g.origin.y - 50.0).abs() < 1e-6 && g.angle.abs() < 1e-6);
|
|
}
|
|
// Vertical path downward: glyphs rotated 90°.
|
|
let mut v = BezPath::new();
|
|
v.move_to((0.0, 0.0));
|
|
v.line_to((0.0, 300.0));
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0 };
|
|
let l = layout(db(), &t);
|
|
for g in &l.glyphs {
|
|
assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
|
|
assert!(g.origin.x.abs() < 1e-6);
|
|
}
|
|
let (top, bot) = caret_position(&l, 0);
|
|
assert!(top.x > 0.0 && bot.x < 0.0, "caret perpendicular to path: {top:?} {bot:?}");
|
|
let mid = l.glyphs[1].origin + Vec2::new(0.0, 0.2);
|
|
assert_eq!(hit_byte(&l, mid), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn on_path_effects_orient_glyphs() {
|
|
use vectorcraft_doc::PathEffect;
|
|
let mut diag = BezPath::new();
|
|
diag.move_to((0.0, 0.0));
|
|
diag.line_to((400.0, 400.0));
|
|
let mut t = point("H", style(40.0));
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1 };
|
|
let bbox = |t: &TextObject| layout(db(), t).glyphs[0].outline.bounding_box();
|
|
let rainbow = bbox(&t);
|
|
t.path_effect = PathEffect::StairStep;
|
|
let stair = bbox(&t);
|
|
// Unrotated: as tall as the cap height, narrower than the rotated glyph's box.
|
|
assert!(stair.height() < rainbow.height() && stair.height() > 20.0, "{stair:?} {rainbow:?}");
|
|
t.path_effect = PathEffect::Skew;
|
|
let skew = bbox(&t);
|
|
// Vertical stems stay vertical: the box spans the cap height plus the slant of the baseline.
|
|
assert!(skew.height() > stair.height() && skew.width() < rainbow.width() + 1e-6, "{skew:?}");
|
|
// Gravity on a circle: glyphs point away from the centre (same as Rainbow on a circle).
|
|
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
|
|
t.path_effect = PathEffect::Rainbow;
|
|
let a = layout(db(), &t).glyphs[0].outline.bounding_box();
|
|
t.path_effect = PathEffect::Gravity;
|
|
let b = layout(db(), &t).glyphs[0].outline.bounding_box();
|
|
assert!((a.center() - b.center()).hypot() < 2.0, "{a:?} {b:?}");
|
|
assert_eq!(PathEffect::parse("3D Ribbon"), Some(PathEffect::Ribbon3d));
|
|
assert_eq!(PathEffect::parse("stair step"), Some(PathEffect::StairStep));
|
|
}
|
|
|
|
#[test]
|
|
fn on_path_circle_and_overflow() {
|
|
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
|
let mut t = point("Around the circle", style(14.0));
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
|
|
let l = layout(db(), &t);
|
|
assert!(!l.overflow);
|
|
for g in &l.glyphs {
|
|
let r = g.origin.to_vec2().hypot();
|
|
assert!((r - 100.0).abs() < 2.0, "{r}");
|
|
}
|
|
let mut short = BezPath::new();
|
|
short.move_to((0.0, 0.0));
|
|
short.line_to((30.0, 0.0));
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0 };
|
|
let l = layout(db(), &t);
|
|
assert!(l.overflow && l.glyphs.len() < t.plain_text().len());
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_font_per_character() {
|
|
let primary = db().face("Source Sans 3", "Regular");
|
|
// Find a character the primary lacks but another bundled font has.
|
|
let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}']
|
|
.into_iter()
|
|
.find(|&c| !primary.covers(c) && db().fallback_for(c, primary.id()).is_some())
|
|
.expect("some symbol only covered by a non-primary bundled font");
|
|
let text = format!("a{c}b");
|
|
let l = layout(db(), &point(&text, style(12.0)));
|
|
assert_eq!(l.glyphs.len(), 3);
|
|
assert_eq!(l.glyphs[0].font_id, primary.id());
|
|
assert_ne!(l.glyphs[1].font_id, primary.id());
|
|
assert!(!l.glyphs[1].outline.elements().is_empty());
|
|
assert_eq!(l.glyphs[2].font_id, primary.id());
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_runs_and_styles() {
|
|
let mut t = point("", style(10.0));
|
|
t.runs = vec![
|
|
TextRun { text: "Big".into(), style: style(40.0) },
|
|
TextRun { text: "small".into(), style: CharStyle { font_family: "Inter".into(), ..style(10.0) } },
|
|
];
|
|
let l = layout(db(), &t);
|
|
assert_eq!(l.glyphs.len(), 8);
|
|
assert_eq!(l.glyphs[3].run, 1);
|
|
assert!(l.lines[0].ascent > 30.0);
|
|
assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_is_fast() {
|
|
let text: String = LOREM.chars().cycle().take(1000).collect();
|
|
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 300.0, 2000.0), Justify::JustifyLeft);
|
|
let _ = layout(db(), &t); // warm caches
|
|
let n = 20;
|
|
let start = std::time::Instant::now();
|
|
for _ in 0..n {
|
|
let l = layout(db(), &t);
|
|
assert_eq!(l.glyphs.len(), 1000);
|
|
}
|
|
let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
|
eprintln!("layout of 1000 chars: {per:.3} ms");
|
|
let budget = if cfg!(debug_assertions) { 100.0 } else { 5.0 };
|
|
assert!(per < budget, "{per} ms");
|
|
}
|