mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Document symbols placed inline in a run of type (beyond Illustrator; like InDesign inline anchored objects): TextRun.inline holds the symbol, scale and shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on the cap height. text.insertInline and Type > Insert Inline Symbol; editing keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create Outlines turns it into a symbol instance.
347 lines
16 KiB
Rust
347 lines
16 KiB
Rust
//! Direction, source-cluster and editing regressions; no RTL font assets required.
|
||
use super::*;
|
||
use kurbo::{Point, Rect, Shape};
|
||
use std::sync::Arc;
|
||
use vectorcraft_doc::{CharStyle, TextKind, TextRun};
|
||
|
||
fn text_layout(s: &str) -> TextLayout {
|
||
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
|
||
}
|
||
|
||
fn arabic_test_face(test: &str) -> Option<Arc<FontFace>> {
|
||
let db = FontDb::global();
|
||
// OpenType (GSUB) shaping only: AAT fonts such as macOS's Geeza Pro shape with `morx`, which
|
||
// takes no context from outside the run, so their joining across a style boundary differs.
|
||
let opentype = |face: &FontFace| {
|
||
use skrifa::raw::TableProvider;
|
||
face.skrifa().is_some_and(|f| f.gsub().is_ok())
|
||
};
|
||
let face = ["Geeza Pro", "Arial", "Noto Sans Arabic"]
|
||
.into_iter()
|
||
.filter_map(|name| db.face(name, "Regular"))
|
||
.find(|face| face.covers('ب') && face.covers('ل') && face.covers('ا') && opentype(face));
|
||
if let Some(face) = &face {
|
||
let path = face.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string());
|
||
eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} ({path})", face.family, face.style);
|
||
} else {
|
||
eprintln!("SKIPPED: {test}; no installed candidate font covers Arabic beh, lam, and alef");
|
||
}
|
||
face
|
||
}
|
||
|
||
fn arabic_style(face: &FontFace) -> CharStyle {
|
||
CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() }
|
||
}
|
||
|
||
fn assert_same_shaped_glyphs(left: &TextLayout, right: &TextLayout) {
|
||
assert_eq!(left.glyphs.len(), right.glyphs.len());
|
||
for (a, b) in left.glyphs.iter().zip(&right.glyphs) {
|
||
assert_eq!(
|
||
(a.gid, a.byte, a.len, a.font_id, a.advance, a.origin, a.xf, a.rtl),
|
||
(b.gid, b.byte, b.len, b.font_id, b.advance, b.origin, b.xf, b.rtl),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hebrew_and_arabic_are_visually_rtl_with_exact_source_clusters() {
|
||
for s in ["שלום", "مرحبا"] {
|
||
let l = text_layout(s);
|
||
assert!(!l.glyphs.is_empty());
|
||
assert!(l.glyphs.iter().all(|g| g.rtl));
|
||
let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect();
|
||
assert!(bytes.windows(2).all(|w| w[0] >= w[1]), "{s}: {bytes:?}");
|
||
for g in &l.glyphs {
|
||
assert!(s.get(g.byte..g.byte + g.len).is_some(), "{g:?}");
|
||
assert!(g.len <= s.len());
|
||
}
|
||
assert!(caret_position(&l, 0).0.x > caret_position(&l, s.len()).0.x);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mixed_text_preserves_latin_and_number_order() {
|
||
let s = "שלום Rust 123";
|
||
let l = text_layout(s);
|
||
let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect();
|
||
assert_eq!(visible, "Rust 123 םולש");
|
||
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
|
||
}
|
||
|
||
#[test]
|
||
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
|
||
let s = "שלום";
|
||
let l = text_layout(s);
|
||
for g in &l.glyphs {
|
||
let p = Point::new(g.origin.x + g.advance * 0.9, g.origin.y);
|
||
assert_eq!(hit_byte(&l, p), g.byte);
|
||
let p = Point::new(g.origin.x + g.advance * 0.1, g.origin.y);
|
||
assert_eq!(hit_byte(&l, p), g.byte + g.len);
|
||
}
|
||
assert_eq!(caret_horizontal(&l, 0, false), 2);
|
||
assert_eq!(caret_horizontal(&l, 2, true), 0);
|
||
assert!(!selection_quads(&l, 0, s.len()).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn wrapping_retains_logical_line_ranges_and_reorders_each_line() {
|
||
let s = "שלום עולם שלום עולם שלום עולם";
|
||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 55.0, 400.0).to_path(0.1)) };
|
||
let l = layout(FontDb::global(), &t);
|
||
assert!(l.lines.len() > 1);
|
||
assert!(!l.overflow);
|
||
for line in &l.lines {
|
||
assert!(s.get(line.start..line.end).is_some());
|
||
for g in &l.glyphs[line.glyph_start..line.glyph_end] {
|
||
assert!(g.byte >= line.start && g.byte + g.len <= line.end);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_direction_is_independent_and_neutrals_do_not_force_ltr() {
|
||
let l = text_layout("123 שלום\nEnglish\nمرحبا");
|
||
assert!(l.glyphs.iter().any(|g| g.line == 0 && g.rtl));
|
||
assert!(l.glyphs.iter().filter(|g| g.line == 1).all(|g| !g.rtl));
|
||
assert!(l.glyphs.iter().filter(|g| g.line == 2).all(|g| g.rtl));
|
||
}
|
||
|
||
#[test]
|
||
fn hebrew_marks_stay_with_base_clusters() {
|
||
let s = "שָׁלוֹם";
|
||
let l = text_layout(s);
|
||
for g in &l.glyphs {
|
||
assert!(s.get(g.byte..g.byte + g.len).is_some());
|
||
}
|
||
assert!(l.glyphs.iter().all(|g| g.rtl));
|
||
}
|
||
|
||
#[test]
|
||
fn arabic_contextual_forms_and_lam_alef_use_the_shaper() {
|
||
let db = FontDb::global();
|
||
// Installed fonts are optional; no proprietary or test font is copied into the repo.
|
||
let Some(face) = arabic_test_face("arabic_contextual_forms_and_lam_alef_use_the_shaper") else { return };
|
||
let st = arabic_style(&face);
|
||
let pair = "لا";
|
||
let lam_alef = layout(db, &TextObject::point(Point::ZERO, pair, st.clone()));
|
||
let ligature = lam_alef.glyphs.iter().find(|g| g.byte == 0 && g.len == pair.len());
|
||
assert!(ligature.is_some(), "lam-alef must retain one cluster spanning both source letters: {lam_alef:?}");
|
||
assert_ne!(ligature.unwrap().gid, face.glyph_for('ل'), "lam-alef must use a shaped glyph");
|
||
|
||
for s in ["سلام", "שלום سلام"] {
|
||
let l = layout(db, &TextObject::point(Point::ZERO, s, st.clone()));
|
||
let arabic: Vec<_> = l.glyphs.iter().filter(|g| s.get(g.byte..).is_some_and(|t| t.starts_with(['س', 'ل', 'ا', 'م']))).collect();
|
||
assert!(
|
||
arabic.iter().any(|g| s.get(g.byte..).and_then(|t| t.chars().next()).is_some_and(|c| g.gid != face.glyph_for(c))),
|
||
"Arabic must use contextual forms: {s}"
|
||
);
|
||
assert!(arabic.iter().all(|g| g.rtl));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn identical_style_arabic_run_splits_keep_joining_and_source_attribution() {
|
||
let db = FontDb::global();
|
||
let Some(face) = arabic_test_face("identical_style_arabic_run_splits_keep_joining_and_source_attribution") else { return };
|
||
let style = arabic_style(&face);
|
||
let text = "بب";
|
||
|
||
let mut single = TextObject::point(Point::ZERO, text, style.clone());
|
||
single.runs = vec![TextRun { text: text.to_string(), style: style.clone(), inline: None }];
|
||
let mut split = TextObject::point(Point::ZERO, text, style.clone());
|
||
split.runs = vec![TextRun { text: "ب".into(), style: style.clone(), inline: None }, TextRun { text: "ب".into(), style, inline: None }];
|
||
|
||
let single = layout(db, &single);
|
||
let split = layout(db, &split);
|
||
assert!(single.glyphs.iter().any(|glyph| glyph.gid != face.glyph_for('ب')), "single run must exercise contextual beh forms");
|
||
assert_same_shaped_glyphs(&single, &split);
|
||
assert_eq!(split.glyphs.len(), 2, "beh-beh should remain two source clusters");
|
||
for glyph in &split.glyphs {
|
||
let expected_run = usize::from(glyph.byte >= "ب".len());
|
||
assert_eq!(glyph.run, expected_run, "source byte {} belongs to run {expected_run}", glyph.byte);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn different_style_arabic_boundary_receives_joining_context() {
|
||
let db = FontDb::global();
|
||
let Some(face) = arabic_test_face("different_style_arabic_boundary_receives_joining_context") else { return };
|
||
let style = arabic_style(&face);
|
||
let text = "بب";
|
||
let joined = layout(db, &TextObject::point(Point::ZERO, text, style.clone()));
|
||
assert!(joined.glyphs.iter().any(|glyph| glyph.gid != face.glyph_for('ب')), "reference must use contextual beh forms");
|
||
|
||
let mut split = TextObject::point(Point::ZERO, text, style.clone());
|
||
split.runs = vec![
|
||
TextRun { text: "ب".into(), style: style.clone(), inline: None },
|
||
TextRun { text: "ب".into(), style: CharStyle { size: style.size * 1.5, ..style }, inline: None },
|
||
];
|
||
let split = layout(db, &split);
|
||
assert_eq!(joined.glyphs.len(), 2);
|
||
assert_eq!(split.glyphs.len(), 2);
|
||
for glyph in &joined.glyphs {
|
||
let actual = split.glyphs.iter().find(|other| other.byte == glyph.byte).expect("same source cluster");
|
||
assert_eq!(actual.gid, glyph.gid, "joining form changed at style boundary for byte {}", glyph.byte);
|
||
assert_eq!(actual.len, glyph.len);
|
||
assert_eq!(actual.run, usize::from(glyph.byte >= "ب".len()));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shape_range_handles_empty_runs_and_selected_utf8_subranges() {
|
||
let db = FontDb::global();
|
||
let style = CharStyle::default();
|
||
let text = "Aب";
|
||
let runs = [(0..text.len(), &style)];
|
||
let mut glyphs = Vec::new();
|
||
|
||
shape::shape_range(db, text, 0..0, &runs, &[], &OtFeatures::default(), &[], &mut glyphs);
|
||
shape::shape_range(db, text, 0..text.len(), &[], &[], &OtFeatures::default(), &[], &mut glyphs);
|
||
shape::shape_range(db, text, 1..2, &runs, &[], &OtFeatures::default(), &[], &mut glyphs);
|
||
assert!(glyphs.is_empty());
|
||
|
||
shape::shape_range(db, text, 1..text.len(), &runs, &[], &OtFeatures::default(), &[], &mut glyphs);
|
||
assert!(!glyphs.is_empty());
|
||
assert!(glyphs.iter().all(|g| (1..text.len()).contains(&g.byte)));
|
||
}
|
||
|
||
#[test]
|
||
fn bidi_isolates_numbers_and_brackets_keep_valid_clusters() {
|
||
for s in ["שלום (123)", "مرحبا (Rust 42)", "English \u{2067}שלום 123\u{2069} end"] {
|
||
let l = text_layout(s);
|
||
for g in &l.glyphs {
|
||
assert!(s.get(g.byte..g.byte + g.len).is_some());
|
||
}
|
||
assert!(!selection_quads(&l, 0, s.len()).is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn right_aligned_wrapped_rtl_keeps_trailing_spaces_outside_content() {
|
||
let mut t = TextObject::point(Point::ZERO, "שלום עולם שלום עולם שלום עולם", CharStyle::default());
|
||
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 400.0).to_path(0.1)) };
|
||
t.para.justify = vectorcraft_doc::Justify::Right;
|
||
let l = layout(FontDb::global(), &t);
|
||
assert!(l.lines.len() > 1);
|
||
for line in &l.lines {
|
||
assert!((line.x1 - 80.0).abs() < 1e-6, "{line:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn visual_arrows_cross_rtl_paragraph_boundaries() {
|
||
let l = text_layout("שלום\nעולם");
|
||
assert!(l.lines.iter().all(|l| l.rtl));
|
||
assert_eq!(caret_horizontal(&l, 8, false), 9);
|
||
assert_eq!(caret_horizontal(&l, 9, true), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn automatic_alignment_follows_each_paragraph_and_explicit_choices_win() {
|
||
let db = FontDb::global();
|
||
for s in ["שלום", "مرحبا", "123 שלום"] {
|
||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||
t.para.justify = vectorcraft_doc::Justify::Auto;
|
||
let l = layout(db, &t);
|
||
assert!((l.lines[0].x1).abs() < 1e-6, "RTL point type grows left from its anchor: {s}");
|
||
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)) };
|
||
let l = layout(db, &t);
|
||
assert!((l.lines[0].x1 - 200.0).abs() < 1e-6);
|
||
t.para.justify = vectorcraft_doc::Justify::Left;
|
||
assert_eq!(layout(db, &t).lines[0].x0, 0.0, "explicit left alignment is preserved");
|
||
t.para.justify = vectorcraft_doc::Justify::Center;
|
||
let l = layout(db, &t);
|
||
assert!((l.lines[0].x0 + l.lines[0].x1 - 200.0).abs() < 1e-6);
|
||
}
|
||
let mut t = TextObject::point(Point::ZERO, "English\nשלום\nمرحبا\nEnglish again", CharStyle::default());
|
||
t.para.justify = vectorcraft_doc::Justify::Auto;
|
||
let l = layout(db, &t);
|
||
assert_eq!(l.lines[0].x0, 0.0);
|
||
assert!(l.lines[1].x0 < 0.0 && l.lines[1].x1.abs() < 1e-6);
|
||
assert!(l.lines[2].x0 < 0.0 && l.lines[2].x1.abs() < 1e-6);
|
||
assert_eq!(l.lines[3].x0, 0.0);
|
||
}
|
||
|
||
/// Paragraph Direction set on the text wins over its first strong character: English set right to
|
||
/// left keeps its words but ends at the start of its line (Auto alignment: the right), its full
|
||
/// stop on the left; Hebrew set left to right starts on the left.
|
||
#[test]
|
||
fn paragraph_direction_overrides_the_first_strong_character() {
|
||
use vectorcraft_doc::{Justify, ParaDirection};
|
||
let db = FontDb::global();
|
||
let order = |s: &str, direction| {
|
||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||
(t.para.justify, t.para.direction) = (Justify::Auto, direction);
|
||
let l = layout(db, &t);
|
||
let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect();
|
||
(visible, l.lines[0].rtl, l.lines[0].x1)
|
||
};
|
||
let (visible, rtl, x1) = order("Hello.", Some(ParaDirection::RightToLeft));
|
||
assert_eq!((visible.as_str(), rtl), (".Hello", true));
|
||
assert!(x1.abs() < 1e-6, "aligned to the right of the anchor: {x1}");
|
||
assert_eq!(order("Hello.", None).0, "Hello.");
|
||
assert!(!order("Hello.", None).1);
|
||
assert_eq!(order("שלום abc", None).0, "abc םולש", "a Hebrew paragraph starts on the right");
|
||
let (visible, rtl, _) = order("שלום abc", Some(ParaDirection::LeftToRight));
|
||
assert_eq!((visible.as_str(), rtl), ("םולש abc", false), "set left to right, the Hebrew word comes first on the left");
|
||
assert!(crate::paragraph_is_rtl("123 שלום", None) && !crate::paragraph_is_rtl("123 שלום", Some(ParaDirection::LeftToRight)));
|
||
}
|
||
|
||
/// Plain left-to-right text skips the bidi algorithm: nothing is marked right to left.
|
||
#[test]
|
||
fn left_to_right_text_is_untouched() {
|
||
let l = text_layout("Plain text, 123 (and more).");
|
||
assert!(l.glyphs.iter().all(|g| !g.rtl) && l.lines.iter().all(|l| !l.rtl));
|
||
let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect();
|
||
assert!(bytes.windows(2).all(|w| w[0] < w[1]));
|
||
}
|
||
|
||
/// Text drawn in visual order (from a PDF) comes back in logical order, with the direction that
|
||
/// shows it as it was drawn; left-to-right text is left alone.
|
||
#[test]
|
||
fn visual_text_comes_back_in_logical_order() {
|
||
let logical = |visual: &str| {
|
||
let (order, rtl) = logical_order(visual)?;
|
||
let chars: Vec<char> = visual.chars().collect();
|
||
Some((order.iter().filter_map(|&i| chars.get(i)).collect::<String>(), rtl))
|
||
};
|
||
assert_eq!(logical("םולש"), Some(("שלום".to_string(), true)));
|
||
assert_eq!(logical("123 םולש"), Some(("שלום 123".to_string(), true)));
|
||
assert_eq!(logical("abc םולש"), Some(("abc שלום".to_string(), false)));
|
||
assert_eq!(logical("plain text"), None);
|
||
// Laid out with that direction, the logical text shows as drawn.
|
||
for visual in ["123 םולש", "abc םולש"] {
|
||
let (text, rtl) = logical(visual).unwrap();
|
||
let mut t = TextObject::point(Point::ZERO, &text, CharStyle::default());
|
||
t.para.direction = Some(if rtl { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight });
|
||
let l = layout(FontDb::global(), &t);
|
||
let shown: String = l.glyphs.iter().filter_map(|g| text.get(g.byte..)?.chars().next()).collect();
|
||
assert_eq!(shown, visual);
|
||
}
|
||
}
|
||
|
||
/// Paragraph Direction is a paragraph attribute: in one text object a right-to-left paragraph
|
||
/// reads and aligns (Auto) right to left while the next, left to right, starts on the left.
|
||
#[test]
|
||
fn paragraph_direction_is_per_paragraph() {
|
||
use vectorcraft_doc::{Justify, ParaDirection, ParaStyle};
|
||
let s = "Hello.\nHello.";
|
||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||
let dir = |direction| ParaStyle { justify: Justify::Auto, direction, ..ParaStyle::default() };
|
||
t.set_paragraph_styles(vec![dir(Some(ParaDirection::RightToLeft)), dir(Some(ParaDirection::LeftToRight))]);
|
||
assert_eq!(t.paras.len(), 2, "stored per paragraph");
|
||
let l = layout(FontDb::global(), &t);
|
||
assert_eq!(l.lines.len(), 2);
|
||
let shown = |line: &crate::LineInfo| -> String {
|
||
let mut gs: Vec<_> = l.glyphs.iter().filter(|g| g.byte >= line.start && g.byte < line.end).collect();
|
||
gs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
|
||
gs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).filter(|c| *c != '\n').collect()
|
||
};
|
||
assert!(l.lines[0].rtl && !l.lines[1].rtl);
|
||
assert_eq!(shown(&l.lines[0]), ".Hello", "the first paragraph runs right to left");
|
||
assert_eq!(shown(&l.lines[1]), "Hello.", "the second runs left to right");
|
||
assert!(l.lines[0].x1.abs() < 1e-6, "Auto aligns the right-to-left paragraph right: {}", l.lines[0].x1);
|
||
assert!(l.lines[1].x0.abs() < 1e-6, "…and the left-to-right one left: {}", l.lines[1].x0);
|
||
}
|