Files
vectorcraft/crates/text/src/tests_bidi.rs
bflatasticandClaude Opus 5.5 0687d2d127 Type: Paragraph Direction instead of an Automatic alignment toggle; bidi only where needed (review of #300)
Illustrator sets a paragraph's direction (Left-to-Right / Right-to-Left
Paragraph Direction in the Paragraph panel, with the Middle Eastern
options) and keeps its alignment buttons physical; it has no automatic
alignment toggle. So:

- ParaStyle.direction (None: from each paragraph's first strong
  character, as before; written only when set), the Paragraph panel's two
  direction buttons with Show Indic Options (original icons), and
  text.setFormat {direction: "auto"|"leftToRight"|"rightToLeft"}. The
  layout uses it as the base direction.
- The Automatic alignment check box is gone. Justify::Auto stays as the
  start of the paragraph's direction, set on new type only (text.create,
  the Type tools, with the mojikumi default in one new_type_para()):
  TextObject::point is Left again, so importers (PDF, SVG, DXF, EMF) keep
  their own alignment. The alignment buttons show what Auto resolves to.
- Bidi only where there is something right to left: plain paragraphs skip
  the algorithm (no per-layout allocation), it runs once per paragraph
  (shaping takes the paragraph's levels instead of recomputing them; path
  type computed it four times), and the shaper keeps Han, Hiragana,
  Katakana and Bopomofo in one segment (splitting at every change of
  script cost Japanese text a shaper call per kana-kanji switch).
- Vertical type keeps its logical order, so its glyphs and lines are not
  marked right to left (the caret no longer runs backwards down a column).
- PDF import puts Hebrew and Arabic, which PDFs (and VectorCraft's own
  export) draw in visual order, back in logical order with the direction
  that shows it as drawn; with bidi layout it came back mirrored.
- SVG import pins the direction SVG implies (left to right unless
  direction: rtl); SVG export also outlines right-to-left paragraphs.
- Docs (development.md), ROADMAP (folded into the Type entry, Type core
  row) and README updated; catalog rows for the new labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:25:01 +07:00

217 lines
9.8 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.
//! Direction, source-cluster and editing regressions; no RTL font assets required.
use super::*;
use kurbo::{Point, Rect, Shape};
use vectorcraft_doc::{CharStyle, TextKind};
fn text_layout(s: &str) -> TextLayout {
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
}
#[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 face =
["Geeza Pro", "Arial", "Noto Sans Arabic"].into_iter().filter_map(|name| db.face(name, "Regular")).find(|f| f.covers('ل') && f.covers('ا'));
let Some(face) = face else { return };
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() };
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 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);
}
}