vectorcraft/crates/text/src/tests_inline.rs

329 lines
16 KiB
Rust
Raw Permalink 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.

//! Inline graphics in text: a symbol's art laid out like a glyph.
use super::*;
use kurbo::{Affine, Shape};
use vectorcraft_doc::text::INLINE_CHAR;
use vectorcraft_doc::{CharStyle, InlineArt, Justify, TextKind, TextRun};
use vectorcraft_geom::PathData;
use crate::edit;
fn db() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(vec![]))
}
fn style(size: f64) -> CharStyle {
CharStyle { size, ..CharStyle::default() }
}
/// Art 20 × 10 (2:1), as a symbol's resolved bounds.
fn art(scale: f64) -> InlineArt {
InlineArt { scale, bounds: Some(Rect::new(-10.0, -5.0, 10.0, 5.0)), ..InlineArt::new("mana") }
}
/// Point type: `before`, the inline graphic, `after`.
fn text(before: &str, a: InlineArt, after: &str, size: f64) -> TextObject {
let mut t = TextObject::point(Point::ZERO, before, style(size));
t.runs.push(TextRun::inline(a, style(size)));
t.runs.push(TextRun::new(after, style(size)));
t
}
fn inline_glyph(l: &TextLayout) -> &PositionedGlyph {
l.glyphs.iter().find(|g| g.run == 1).expect("inline glyph")
}
#[test]
fn inline_art_advances_by_its_scaled_width_and_centres_on_the_cap_height() {
let t = text("A", art(1.0), "B", 20.0);
let l = layout(db(), &t);
let g = inline_glyph(&l);
// 20 pt tall → 40 pt wide (2:1), no outline of its own.
assert!((g.advance - 40.0).abs() < 1e-6, "{}", g.advance);
assert!(g.outline.elements().is_empty());
assert_eq!(g.len, INLINE_CHAR.len_utf8());
assert_eq!(l.inlines.len(), 1);
let ig = &l.inlines[0];
assert_eq!((ig.run, ig.byte, ig.glyph), (1, 1, l.glyphs.iter().position(|g| g.run == 1).unwrap()));
// Left edge on the pen; vertical centre half the cap height above the baseline.
let face = db().face("Source Sans 3", "Regular").unwrap();
let cap = face.cap_height * 20.0 / face.upem;
let b = ig.bounds;
assert!((b.x0 - g.origin.x).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6 && (b.height() - 20.0).abs() < 1e-6, "{b:?}");
assert!((b.center().y + cap * 0.5).abs() < 1e-6, "{b:?} cap {cap}");
// The art's transform maps its bounds there.
let mapped = ig.xf.transform_rect_bbox(Rect::new(-10.0, -5.0, 10.0, 5.0));
assert!((mapped.x0 - b.x0).abs() < 1e-6 && (mapped.y1 - b.y1).abs() < 1e-6);
// `B` follows it; the layout bounds include it.
let next = l.glyphs.iter().find(|g| g.run == 2).unwrap();
assert!((next.origin.x - (g.origin.x + 40.0)).abs() < 1e-6);
assert!(l.bounds.contains(b.origin()) && l.bounds.x1 >= b.x1 - 1e-9);
}
#[test]
fn shift_tracking_and_missing_symbols() {
let up = InlineArt { baseline_shift: 4.0, ..art(1.0) };
let a = layout(db(), &text("A", art(1.0), "B", 20.0)).inlines[0].bounds;
let b = layout(db(), &text("A", up, "B", 20.0)).inlines[0].bounds;
assert!((a.y0 - b.y0 - 4.0).abs() < 1e-6, "{a:?} {b:?}");
// Tracking adds to the advance as it does after a glyph.
let mut t = text("A", art(1.0), "B", 20.0);
t.runs[1].style.tracking = 100.0;
assert!((inline_glyph(&layout(db(), &t)).advance - 42.0).abs() < 1e-6);
// Unresolved (missing symbol): a square of the height, nothing to draw.
let t = text("A", InlineArt::new("gone"), "B", 20.0);
let l = layout(db(), &t);
assert!((inline_glyph(&l).advance - 20.0).abs() < 1e-6);
assert!(l.inlines.is_empty());
// Junk numbers stay finite.
let t = text("A", InlineArt { scale: f64::NAN, baseline_shift: f64::INFINITY, ..art(1.0) }, "B", 20.0);
let l = layout(db(), &t);
assert!(l.bounds.is_finite() && l.glyphs.iter().all(|g| g.advance.is_finite()));
}
#[test]
fn tall_inline_art_opens_up_its_line() {
let plain = layout(db(), &text("Ab", art(1.0), "cd", 20.0));
let tall = layout(db(), &text("Ab", art(3.0), "cd", 20.0));
let face = db().face("Source Sans 3", "Regular").unwrap();
let cap = face.cap_height * 20.0 / face.upem;
// At scale 1 the art stays inside the font's ascent and descent: the line doesn't change.
let font_only = layout(db(), &TextObject::point(Point::ZERO, "Abcd", style(20.0)));
assert!((plain.lines[0].ascent - font_only.lines[0].ascent).abs() < 1e-6);
assert!((plain.lines[0].descent - font_only.lines[0].descent).abs() < 1e-6);
// 60 pt tall: half above the cap-height middle, half below.
assert!((tall.lines[0].ascent - (cap * 0.5 + 30.0)).abs() < 1e-6, "{:?}", tall.lines[0]);
assert!((tall.lines[0].descent - (30.0 - cap * 0.5)).abs() < 1e-6);
// A second line moves down by the taller leading (Auto: 120% of the size) only where the
// art is; the line's band (caret, selection, hit testing) covers the art.
let mut t = text("Ab", art(3.0), "cd\nef", 20.0);
t.runs[1].style.leading = None;
let l = layout(db(), &t);
assert_eq!(l.lines.len(), 2);
let (top, bottom) = caret_position(&l, 3);
assert!(top.y <= l.inlines[0].bounds.y0 + 1e-6 && bottom.y >= l.inlines[0].bounds.y1 - 1e-6);
let mid = l.inlines[0].bounds.center();
assert_eq!(l.line_of(hit_byte(&l, mid)), 0);
}
#[test]
fn line_breaking_moves_an_inline_graphic_like_a_word() {
// The frame is just wider than "tap tap ": the 40 pt graphic doesn't fit after it.
let w: f64 = layout(db(), &TextObject::point(Point::ZERO, "tap tap ", style(20.0))).glyphs.iter().map(|g| g.advance).sum();
let frame = Rect::new(0.0, 0.0, w + 10.0, 200.0);
let area = |t: &mut TextObject| {
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = Justify::Left;
};
let mut t = text("tap tap ", art(1.0), " done", 20.0);
area(&mut t);
let l = layout(db(), &t);
let g = inline_glyph(&l);
assert_eq!(g.line, 1, "the graphic wraps to the next line");
// It starts that line, at the frame's left edge, its art with it.
assert!((g.origin.x - l.lines[1].x0).abs() < 1e-6 && l.lines[1].x0.abs() < 1e-6);
assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6);
assert!(l.inlines[0].bounds.center().y > l.lines[0].baseline);
// No break between the graphic and punctuation stuck to it.
let mut t2 = text("tap tap ", art(1.0), ".", 20.0);
area(&mut t2);
let l2 = layout(db(), &t2);
assert_eq!(l2.glyphs.iter().find(|g| g.run == 2).unwrap().line, inline_glyph(&l2).line);
}
#[test]
fn inline_art_on_a_path_and_in_vertical_type_moves_with_the_text() {
let mut t = text("ab", art(1.0), "cd", 20.0);
let path = kurbo::Line::new((0.0, 0.0), (400.0, 0.0)).to_path(0.1);
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&path), start: 0.0, end: None };
let l = layout(db(), &t);
assert_eq!(l.inlines.len(), 1);
let g = inline_glyph(&l);
assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6);
let mut v = text("ab", art(1.0), "cd", 20.0);
v.vertical = true;
let l = layout(db(), &v);
assert_eq!(l.inlines.len(), 1);
assert!(l.bounds.contains(l.inlines[0].bounds.center()));
}
#[test]
fn snapping_moves_inline_art_with_its_glyph() {
let mut l = layout(db(), &text("A", art(1.0), "B", 20.0));
let before = (l.inlines[0].bounds, inline_glyph(&l).origin);
l.snap_to_pixels(Affine::translate((0.3, 0.3)));
let after = (l.inlines[0].bounds, inline_glyph(&l).origin);
let d = after.1 - before.1;
assert!(((after.0.x0 - before.0.x0) - d.x).abs() < 1e-9 && ((after.0.y0 - before.0.y0) - d.y).abs() < 1e-9);
}
#[test]
fn editing_keeps_inline_runs_whole() {
let t = text("ab", art(1.0), "cd", 12.0);
let mut runs = t.runs.clone();
// Same style on both sides: the plain runs still never merge into the graphic.
edit::normalize(&mut runs);
assert_eq!(runs.len(), 3);
assert!(runs[1].inline.is_some() && runs[1].text == INLINE_CHAR.to_string());
// Typing next to it makes plain text.
let caret = edit::replace_range(&mut runs, 5, 5, "X");
assert_eq!(caret, 6);
assert_eq!(runs.len(), 3);
assert_eq!(runs[2].text, "Xcd");
let caret = edit::replace_range(&mut runs, 2, 2, "Y");
assert_eq!((caret, runs[0].text.as_str()), (3, "abY"));
// An offset inside its character lands before it: nothing splits it.
let plain: String = runs.iter().map(|r| r.text.as_str()).collect();
let at = plain.find(INLINE_CHAR).unwrap();
edit::replace_range(&mut runs, at + 1, at + 1, "Z");
assert!(runs.iter().any(|r| r.inline.is_some() && r.text == INLINE_CHAR.to_string()));
// Backspace over it (its one character) removes the whole graphic.
let plain: String = runs.iter().map(|r| r.text.as_str()).collect();
let at = plain.find(INLINE_CHAR).unwrap();
let prev = edit::prev_char(&plain, at + INLINE_CHAR.len_utf8());
assert_eq!(prev, at);
edit::replace_range(&mut runs, prev, at + INLINE_CHAR.len_utf8(), "");
assert!(runs.iter().all(|r| r.inline.is_none()));
assert!(!runs.iter().any(|r| r.text.contains(INLINE_CHAR)));
}
#[test]
fn copying_keeps_the_graphic_and_plain_text_shows_the_replacement_character() {
let t = text("ab", art(2.0), "cd", 12.0);
let copy = edit::slice_runs(&t.runs, 1, 6);
let plain: String = copy.iter().map(|r| r.text.as_str()).collect();
assert_eq!(plain, format!("b{INLINE_CHAR}c"));
assert_eq!(copy[1].inline.as_ref().map(|a| a.scale), Some(2.0));
// Half of its character is nothing (no empty graphic runs).
let half = edit::slice_runs(&t.runs, 2, 3);
assert!(half.is_empty(), "{half:?}");
// Pasting it back keeps it.
let mut runs = t.runs.clone();
edit::replace_range_styled(&mut runs, 0, 0, &copy);
assert_eq!(runs.iter().filter(|r| r.inline.is_some()).count(), 2);
}
#[test]
fn malformed_inline_runs_are_plain_text() {
let mut runs = vec![TextRun { text: "abc".into(), style: style(12.0), inline: Some(art(1.0)) }];
edit::normalize(&mut runs);
assert!(runs[0].inline.is_none());
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.runs = vec![TextRun { text: format!("{INLINE_CHAR}{INLINE_CHAR}"), style: style(12.0), inline: Some(art(1.0)) }];
let l = layout(db(), &t);
assert!(l.inlines.is_empty());
}
/// An inline graphic is an object replacement character (U+FFFC, a bidi neutral): in right-to-left
/// text it takes the direction of the text around it and the line's reordering places it, between
/// two Hebrew words in reading order (right to left); between two English words set right to left
/// it stays between them.
#[test]
fn inline_art_in_a_right_to_left_paragraph_is_placed_by_the_bidi_reordering() {
use vectorcraft_doc::ParaDirection;
let db = FontDb::global();
let order = |before: &str, after: &str, direction: Option<ParaDirection>| {
let mut t = text(before, art(1.0), after, 20.0);
(t.para.justify, t.para.direction) = (Justify::Auto, direction);
let l = layout(db, &t);
let x = |run: usize| l.glyphs.iter().filter(|g| g.run == run).map(|g| g.origin.x).fold(f64::NAN, f64::min);
let g = inline_glyph(&l).clone();
assert_eq!(l.inlines.len(), 1);
// The art sits at its glyph, inside the line.
assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6, "{:?} {g:?}", l.inlines[0]);
(x(0), g.origin.x, x(2), g.rtl, l.lines[0].rtl)
};
// Hebrew: the first word on the right, the graphic left of it, the second word further left.
let (first, inline, second, inline_rtl, line_rtl) = order("שלום ", " עולם", None);
assert!(line_rtl && inline_rtl);
assert!(second < inline && inline < first, "{second} < {inline} < {first}");
// English set right to left: the words keep their order with the graphic between them.
let (first, inline, second, inline_rtl, line_rtl) = order("Hello", "world", Some(ParaDirection::RightToLeft));
assert!(line_rtl && !inline_rtl);
assert!(first < inline && inline < second, "{first} < {inline} < {second}");
// At the end of a right-to-left paragraph it is the leftmost thing on the line.
let (first, inline, _, inline_rtl, _) = order("שלום", "", None);
assert!(inline_rtl && inline < first);
}
/// Character Alignment: an inline graphic's em box is its run's, whatever its scale. It moves with
/// its run's text when that is smaller than the line's largest characters, and a big graphic in
/// small text doesn't make its line's small characters move.
#[test]
fn character_alignment_treats_inline_art_as_a_character_of_its_run_size() {
use vectorcraft_doc::CharAlign;
let centre_y = |a: CharAlign, scale: f64, big: f64| {
let mut t = TextObject::point(Point::ZERO, "M", style(big));
t.runs.push(TextRun::inline(art(scale), CharStyle { char_align: a, ..style(20.0) }));
t.runs.push(TextRun::new("x", CharStyle { char_align: a, ..style(20.0) }));
let l = layout(db(), &t);
let x = l.glyphs.iter().find(|g| g.run == 2).map(|g| g.origin.y).unwrap();
(l.inlines[0].bounds.center().y, x)
};
let face = db().face("Source Sans 3", "Regular").unwrap();
let c = face.ideographic_centre();
// Next to a 40 pt character, the 20 pt run (graphic and letter alike) moves up by the same.
let (roman, roman_x) = centre_y(CharAlign::RomanBaseline, 1.0, 40.0);
for (a, k) in [(CharAlign::EmBoxTop, 0.5), (CharAlign::EmBoxCenter, 0.0), (CharAlign::EmBoxBottom, -0.5)] {
let (y, x) = centre_y(a, 1.0, 40.0);
let want = (c + k) * 20.0;
assert!((roman - y - want).abs() < 1e-6, "{a:?}: {} vs {want}", roman - y);
assert!(((roman_x - x) - (roman - y)).abs() < 1e-6, "{a:?}: the graphic moves with its run's letters");
}
// A 60 pt tall graphic in a line of 20 pt text: the line's largest em is still 20 pt, so
// nothing moves.
let (roman, roman_x) = centre_y(CharAlign::RomanBaseline, 3.0, 20.0);
let (top, top_x) = centre_y(CharAlign::EmBoxTop, 3.0, 20.0);
assert!((roman - top).abs() < 1e-6 && (roman_x - top_x).abs() < 1e-6);
}
/// Top-to-Top leading measures from the em box tops: an inline graphic's is its run's, so a line
/// with a tall graphic is spaced like its text (as Roman leading spaces it by its run's leading).
#[test]
fn top_to_top_leading_spaces_inline_art_by_its_run() {
use vectorcraft_doc::LeadingModel;
let lines = |scale: f64| {
let mut t = text("Ab", art(scale), "cd\nef", 20.0);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1)) };
t.para.leading_model = LeadingModel::EmBoxTop;
let l = layout(db(), &t);
assert_eq!(l.lines.len(), 2);
(l.lines[0].baseline, l.lines[1].baseline)
};
let (small0, small1) = lines(1.0);
let (tall0, tall1) = lines(3.0);
assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}");
}
#[test]
fn inline_art_moves_with_vertically_aligned_lines() {
use vectorcraft_doc::text::{AreaOptions, VerticalAlign};
// A rectangle (lines shift) and a triangle (text flows again lower down).
let rect = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1);
let mut tri = kurbo::BezPath::new();
tri.move_to((0.0, 0.0));
tri.line_to((800.0, 0.0));
tri.line_to((400.0, 400.0));
tri.close_path();
for frame in [rect, tri] {
for align in [VerticalAlign::Center, VerticalAlign::Bottom] {
let mut t = text("A", art(1.0), "B", 20.0);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
t.area = AreaOptions { vertical_align: align, ..AreaOptions::default() };
let l = layout(db(), &t);
assert_eq!(l.inlines.len(), 1, "{align:?}");
let g = inline_glyph(&l);
let line = &l.lines[g.line];
assert!(line.baseline > 100.0, "{align:?}: not aligned, baseline {}", line.baseline);
// Still centred on the cap height above its (moved) baseline.
let face = db().face("Source Sans 3", "Regular").unwrap();
let cap = face.cap_height * 20.0 / face.upem;
let b = l.inlines[0].bounds;
assert!((b.center().y - (line.baseline - cap * 0.5)).abs() < 1e-6, "{align:?}: {b:?} baseline {}", line.baseline);
assert!((b.x0 - g.origin.x).abs() < 1e-6, "{align:?}: {b:?}");
}
}
}