Files
vectorcraft/crates/text/src/tests_bidi.rs
T

554 lines
25 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.
//! Direction, source-cluster and editing regressions; no RTL font assets required.
use super::*;
use kurbo::{Affine, BezPath, 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),
);
}
}
fn source_clusters(layout: &TextLayout) -> Vec<(usize, usize)> {
let mut clusters: Vec<_> = layout.glyphs.iter().map(|glyph| (glyph.byte, glyph.len)).collect();
clusters.sort_unstable();
clusters.dedup();
clusters
}
fn assert_source_coverage(text: &str, layout: &TextLayout) {
for glyph in &layout.glyphs {
let end = glyph.byte.checked_add(glyph.len).expect("source cluster end must fit usize");
assert!(glyph.len > 0 && text.get(glyph.byte..end).is_some(), "invalid UTF-8 source cluster: {glyph:?}");
}
let mut covered = 0;
for (byte, len) in source_clusters(layout) {
assert_eq!(byte, covered, "source clusters must cover {text:?} without gaps or overlaps");
covered = byte + len;
}
assert_eq!(covered, text.len(), "source clusters must cover all of {text:?}");
}
#[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());
}
/// Lines of glyphs, one glyph per cluster, each line given in visual order as (byte, len, advance)
/// and starting at x 0.
fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout {
let mut l = TextLayout::default();
for (li, clusters) in lines.iter().enumerate() {
let (glyph_start, baseline) = (l.glyphs.len(), li as f64 * 20.0);
let mut x = 0.0;
for &(byte, len, advance) in *clusters {
l.glyphs.push(PositionedGlyph {
outline: BezPath::new(),
run: 0,
byte,
origin: Point::new(x, baseline),
advance,
len,
angle: 0.0,
line: li,
font_id: 0,
gid: 0,
xf: Affine::IDENTITY,
rtl,
scale: 1.0,
});
x += advance;
}
l.lines.push(LineInfo {
rtl,
baseline,
x0: 0.0,
x1: x,
ascent: 10.0,
descent: 3.0,
first_baseline: 0.0,
start: clusters.iter().map(|c| c.0).min().unwrap_or(0),
end: clusters.iter().map(|c| c.0 + c.1).max().unwrap_or(0),
glyph_start,
glyph_end: l.glyphs.len(),
avail: (0.0, x),
region: 0,
});
}
l
}
fn arrow_steps(l: &TextLayout, from: usize, right: bool, steps: usize) -> Vec<usize> {
let mut caret = from;
(0..steps)
.map(|_| {
caret = caret_horizontal(l, caret, right);
caret
})
.collect()
}
#[test]
fn arrows_step_through_both_caret_stops_of_a_cluster_without_advance() {
// A letter (bytes 0..6), a zero width non-joiner drawn as its own glyph with no advance
// (6..9) and a letter (9..12): the stops at 6 and 9 share one x.
let l = lines_of_clusters(&[&[(0, 6, 10.0), (6, 3, 0.0), (9, 3, 10.0)]], false);
assert_eq!(arrow_steps(&l, 0, true, 4), [6, 9, 12, 12]);
assert_eq!(arrow_steps(&l, 12, false, 4), [9, 6, 0, 0]);
// The same clusters right to left: the glyphs run from byte 9 at the left to byte 0 at the right.
let l = lines_of_clusters(&[&[(9, 3, 10.0), (6, 3, 0.0), (0, 6, 10.0)]], true);
assert_eq!(arrow_steps(&l, 12, true, 4), [9, 6, 0, 0]);
assert_eq!(arrow_steps(&l, 0, false, 4), [6, 9, 12, 12]);
}
#[test]
fn arrows_keep_to_their_line_where_a_wrapped_line_starts_at_the_same_x() {
// A soft wrap at byte 2: the end of the first line is the start of the second, at the same x
// as the start of the first.
let l = lines_of_clusters(&[&[(0, 1, 10.0), (1, 1, 10.0)], &[(2, 1, 10.0), (3, 1, 10.0)]], false);
assert_eq!(arrow_steps(&l, 0, true, 4), [1, 2, 3, 4]);
assert_eq!(caret_horizontal(&l, 1, false), 0);
}
#[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()));
// Fonts may use a ligature or separate contextual lam/alef glyphs, with component marks.
assert_source_coverage(pair, &lam_alef);
for glyph in &lam_alef.glyphs {
assert!(glyph.rtl, "lam-alef must be RTL: {glyph:?}");
assert_eq!(glyph.font_id, face.id());
assert_ne!(glyph.gid, 0, "lam-alef must not use a missing glyph");
let source = pair.get(glyph.byte..glyph.byte + glyph.len).expect("validated UTF-8 source cluster");
assert!(source.chars().all(|c| glyph.gid != face.glyph_for(c)), "lam-alef must use contextual glyphs rather than nominal glyphs: {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);
let beh_len = "ب".len();
assert_eq!(source_clusters(&split), [(0, beh_len), (beh_len, beh_len)], "beh-beh should remain two source clusters");
for glyph in &single.glyphs {
assert_eq!(glyph.run, 0);
}
// Every component, including zero-advance dots, belongs to its original source run.
for glyph in &split.glyphs {
let expected_run = usize::from(glyph.byte >= beh_len);
assert_eq!(
(glyph.run, glyph.byte, glyph.len),
(expected_run, expected_run * beh_len, beh_len),
"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);
let beh_len = "ب".len();
let expected_clusters = [(0, beh_len), (beh_len, beh_len)];
assert_eq!(source_clusters(&joined), expected_clusters);
assert_eq!(source_clusters(&split), expected_clusters);
assert!(joined.glyphs.iter().all(|glyph| glyph.rtl));
// Compare the full component sequence; a first match by byte can hide or reuse a dot.
assert_eq!(split.glyphs.len(), joined.glyphs.len(), "style boundary must retain every component glyph");
for (glyph, actual) in joined.glyphs.iter().zip(&split.glyphs) {
assert_eq!(actual.gid, glyph.gid, "joining form changed at style boundary for byte {}", glyph.byte);
assert_eq!(actual.byte, glyph.byte);
assert_eq!(actual.len, glyph.len);
assert_eq!(actual.font_id, glyph.font_id);
assert_eq!(actual.rtl, glyph.rtl);
assert_eq!(glyph.run, 0);
assert_eq!(
(actual.run, actual.byte, actual.len),
(usize::from(glyph.byte >= beh_len), glyph.byte, beh_len),
"every component must retain its source run and cluster"
);
}
}
#[test]
fn a_change_of_paint_in_a_mixed_direction_line_moves_no_glyph() {
let db = FontDb::global();
let text = "AVAV سلام AVAV";
if db.face_covering('س').is_none_or(|face| !face.covers('ل')) {
eprintln!("SKIPPED: a_change_of_paint_in_a_mixed_direction_line_moves_no_glyph; no font covers Arabic seen and lam");
return;
}
let plain = layout(db, &TextObject::point(Point::ZERO, text, CharStyle::default()));
assert!(plain.glyphs.iter().any(|g| g.rtl) && plain.glyphs.iter().any(|g| !g.rtl), "{plain:?}");
let seen = text.find('س').unwrap_or_default();
let mut restyled = TextObject::point(Point::ZERO, text, CharStyle::default());
for byte in [1, seen] {
let end = byte + text[byte..].chars().next().map_or(1, char::len_utf8);
edit::style_range(&mut restyled.runs, byte, end, |s| s.fill = Default::default());
}
assert_eq!(restyled.runs.len(), 5);
let restyled_layout = layout(db, &restyled);
assert_same_shaped_glyphs(&plain, &restyled_layout);
for glyph in &restyled_layout.glyphs {
let run_ends = restyled.runs.iter().scan(0, |end, r| {
*end += r.text.len();
Some(*end)
});
let expected = run_ends.take_while(|end| *end <= glyph.byte).count();
assert_eq!(glyph.run, expected, "glyph {glyph:?}");
}
}
#[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);
}
#[test]
fn craft_arabic_fonts_load_and_noto_sans_arabic_is_the_arabic_fallback() {
let arabic: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab")).collect();
if arabic.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
}
let db = FontDb::with_font_dirs(vec![]);
for f in &arabic {
assert!(db.has_family(f.family), "{} {} isn't loaded", f.family, f.style);
}
let beh = db.face_covering('ب').expect("some face covers U+0628");
assert_eq!(beh.family, "Noto Sans Arabic");
// Latin still falls back to a bundled face, not to one of the Arabic families.
let a = db.face_covering('a').expect("some face covers 'a'");
assert!(!arabic.iter().any(|f| f.family == a.family), "Latin now falls back to {}", a.family);
}
/// Digits (Character panel menu): the style's numeral system draws every digit, whatever was
/// typed, while the text and its clusters stay as typed. Needs an installed font with the
/// Arabic-Indic digits.
#[test]
fn digits_draw_every_digit_in_the_styles_numeral_system() {
use vectorcraft_doc::Digits;
let db = FontDb::global();
let Some(face) = ["Arial", "Noto Sans Arabic", "Geeza Pro", "Segoe UI", "DejaVu Sans"]
.into_iter()
.filter_map(|name| db.face(name, "Regular"))
.find(|face| face.covers('3') && face.covers('٣') && face.covers('۳'))
else {
eprintln!("SKIPPED: digits_draw_every_digit_in_the_styles_numeral_system; no installed font covers 3, ٣ and ۳");
return;
};
eprintln!("ASSERTIONS RUN: digits_draw_every_digit_in_the_styles_numeral_system; {} {}", face.family, face.style);
let st = |digits: Digits| CharStyle { digits, ..arabic_style(&face) };
let lay = |s: &str, digits: Digits| layout(db, &TextObject::point(Point::ZERO, s, st(digits)));
let gids = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.font_id)).collect::<Vec<_>>();
let clusters = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.len)).collect::<Vec<_>>();
// Typed Western, drawn Arabic-Indic (Hindi) or Eastern Arabic-Indic (Farsi): the glyphs of
// the typed Arabic-Indic digits, on the typed text's clusters.
let typed = lay("page 3", Digits::Default);
let hindi = lay("page 3", Digits::Hindi);
assert_eq!(gids(&hindi), gids(&lay("page ٣", Digits::Default)));
assert_ne!(gids(&hindi), gids(&typed));
assert_eq!(clusters(&hindi), clusters(&typed));
assert_eq!(gids(&lay("page 3", Digits::Farsi)), gids(&lay("page ۳", Digits::Default)));
// Typed Arabic-Indic, drawn Western (Arabic): the Western glyphs on the 2-byte clusters.
let arabic = lay("صفحة ٣", Digits::Arabic);
assert_eq!(gids(&arabic), gids(&lay("صفحة 3", Digits::Default)));
assert_eq!(clusters(&arabic), clusters(&lay("صفحة ٣", Digits::Default)));
// Default leaves each digit as typed.
assert_ne!(gids(&lay("3 ٣ ۳", Digits::Default)), gids(&lay("3 ٣ ۳", Digits::Hindi)));
}