329 lines
16 KiB
Rust
329 lines
16 KiB
Rust
//! 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, ©);
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|