From 3d39bbb31e98889a8c94808d5291c505fccdec03 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:54 +0000 Subject: [PATCH] refactor(text): move layout.rs wrapping_tests to layout/wrapping_tests.rs --- crates/text/src/layout.rs | 154 +---------------------- crates/text/src/layout/wrapping_tests.rs | 151 ++++++++++++++++++++++ 2 files changed, 152 insertions(+), 153 deletions(-) create mode 100644 crates/text/src/layout/wrapping_tests.rs diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f9db3de68..838940ddb 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -304,156 +304,4 @@ impl Ctx<'_> { } #[cfg(test)] -mod wrapping_tests { - use super::*; - use std::sync::Arc; - - fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { - // The face is only a required SGlyph field here; advances and cluster boundaries below are - // entirely synthetic, so the regressions don't depend on a particular font's shaping. - static DB: std::sync::OnceLock = std::sync::OnceLock::new(); - let db = DB.get_or_init(|| FontDb::with_font_dirs(vec![])); - let face = db.face("Source Sans 3", "Regular").expect("bundled test face"); - specs - .iter() - .map(|&(byte, len, adv, ch)| SGlyph { - face: Arc::clone(&face), - gid: 0, - byte, - len, - run: 0, - adv, - dx: 0.0, - dy: 0.0, - sx: 1.0, - sy: 1.0, - bshift: 0.0, - rotation: 0.0, - ascent: 8.0, - descent: 2.0, - leading: 0.0, - cap: 6.0, - xh: 4.0, - ch, - tcy: None, - inline: None, - word_end: false, - lead: 0.0, - proportional: false, - vpal: None, - level: unicode_bidi::Level::ltr(), - no_break: false, - cursive: false, - }) - .collect() - } - - fn source_range(glyphs: &[SGlyph], start: usize, end: usize) -> Range { - let first = glyphs.get(start).expect("nonempty source range"); - let last = glyphs.get(end - 1).expect("nonempty source range"); - first.byte..last.byte + last.len - } - - #[test] - fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { - let text = "بَت"; - let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); - - assert!(!hyphenated); - assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); - assert_eq!(source_range(&glyphs, 0, end), 0..4); - assert_eq!(glyphs.get(end).map(|g| g.byte), Some(4)); - } - - #[test] - fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { - let text = "بَتَث"; - let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); - assert_eq!(first_end, 1); - assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); - - assert!(!hyphenated); - assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); - assert_eq!(source_range(&glyphs, first_end, second_end), 4..8); - assert_eq!(glyphs.get(second_end).map(|g| g.byte), Some(8)); - } - - #[test] - fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { - let db = FontDb::global(); - let Some(face) = db.face_covering('ب').filter(|face| face.covers('َ')) else { - // Arabic integration coverage depends on an installed font; synthetic tests above are - // the font-independent regression oracle. - return; - }; - let text = "بَت"; - let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; - let bidi = para_bidi(text, None); - let levels = bidi.as_ref().map_or(&[][..], |info| &info.levels); - let mut shaped = Vec::new(); - shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &[], &OtFeatures::default(), levels, &mut shaped); - let Some(first) = shaped.first() else { return }; - let first_end = shaped.iter().take_while(|g| g.byte == first.byte).count(); - if first.byte != 0 || first_end < 2 || first_end >= shaped.len() { - return; - } - // Pick a narrow width that fits glyphs before one positive-advance glyph but not that next - // glyph. This ensures the old per-glyph loop would have returned inside the first cluster. - let mut prefix = shaped.first().map_or(0.0, |g| g.adv); - let width = (1..first_end).find_map(|k| { - let advance = shaped.get(k)?.adv; - if prefix > EPS && advance > EPS { - Some(prefix) - } else { - prefix += advance; - None - } - }); - let Some(width) = width else { return }; - - let mut object = TextObject::point(Point::ZERO, text, style); - object.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 100.0).to_path(0.1)) }; - let result = layout_with(db, &object, &LayoutOptions::default()); - - let next_cluster_byte = shaped.get(first_end).map(|g| g.byte); - assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte); - } - - /// No Break keeps the marked characters on one line, including across a space, and moves a - /// marked run that does not fit to the next line whole. - #[test] - fn no_break_keeps_marked_characters_on_one_line() { - let text = "A B"; - let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); - assert!(!hyphen); - assert_eq!(end, 2, "without the mark the line ends after the space"); - - let mut marked = plain; - for g in &mut marked { - g.no_break = true; - } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); - assert!(!hyphen); - assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); - - // 日 and 本 may break between them. Marked, they move together when they do not fit after "A ". - let text = "A 日本"; - let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); - glyphs[2].no_break = true; - glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); - assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); - assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); - - let breaks = candidates(text, &glyphs, None, Kinsoku::None); - assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); - } -} +mod wrapping_tests; diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs new file mode 100644 index 000000000..6be50fbd8 --- /dev/null +++ b/crates/text/src/layout/wrapping_tests.rs @@ -0,0 +1,151 @@ +use super::*; +use std::sync::Arc; + +fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { + // The face is only a required SGlyph field here; advances and cluster boundaries below are + // entirely synthetic, so the regressions don't depend on a particular font's shaping. + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + let db = DB.get_or_init(|| FontDb::with_font_dirs(vec![])); + let face = db.face("Source Sans 3", "Regular").expect("bundled test face"); + specs + .iter() + .map(|&(byte, len, adv, ch)| SGlyph { + face: Arc::clone(&face), + gid: 0, + byte, + len, + run: 0, + adv, + dx: 0.0, + dy: 0.0, + sx: 1.0, + sy: 1.0, + bshift: 0.0, + rotation: 0.0, + ascent: 8.0, + descent: 2.0, + leading: 0.0, + cap: 6.0, + xh: 4.0, + ch, + tcy: None, + inline: None, + word_end: false, + lead: 0.0, + proportional: false, + vpal: None, + level: unicode_bidi::Level::ltr(), + no_break: false, + cursive: false, + }) + .collect() +} + +fn source_range(glyphs: &[SGlyph], start: usize, end: usize) -> Range { + let first = glyphs.get(start).expect("nonempty source range"); + let last = glyphs.get(end - 1).expect("nonempty source range"); + first.byte..last.byte + last.len +} + +#[test] +fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { + let text = "بَت"; + let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); + + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + + assert!(!hyphenated); + assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); + assert_eq!(source_range(&glyphs, 0, end), 0..4); + assert_eq!(glyphs.get(end).map(|g| g.byte), Some(4)); +} + +#[test] +fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { + let text = "بَتَث"; + let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); + + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + assert_eq!(first_end, 1); + assert_eq!(source_range(&glyphs, 0, first_end), 0..4); + + let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + + assert!(!hyphenated); + assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); + assert_eq!(source_range(&glyphs, first_end, second_end), 4..8); + assert_eq!(glyphs.get(second_end).map(|g| g.byte), Some(8)); +} + +#[test] +fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { + let db = FontDb::global(); + let Some(face) = db.face_covering('ب').filter(|face| face.covers('َ')) else { + // Arabic integration coverage depends on an installed font; synthetic tests above are + // the font-independent regression oracle. + return; + }; + let text = "بَت"; + let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + let bidi = para_bidi(text, None); + let levels = bidi.as_ref().map_or(&[][..], |info| &info.levels); + let mut shaped = Vec::new(); + shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &[], &OtFeatures::default(), levels, &mut shaped); + let Some(first) = shaped.first() else { return }; + let first_end = shaped.iter().take_while(|g| g.byte == first.byte).count(); + if first.byte != 0 || first_end < 2 || first_end >= shaped.len() { + return; + } + // Pick a narrow width that fits glyphs before one positive-advance glyph but not that next + // glyph. This ensures the old per-glyph loop would have returned inside the first cluster. + let mut prefix = shaped.first().map_or(0.0, |g| g.adv); + let width = (1..first_end).find_map(|k| { + let advance = shaped.get(k)?.adv; + if prefix > EPS && advance > EPS { + Some(prefix) + } else { + prefix += advance; + None + } + }); + let Some(width) = width else { return }; + + let mut object = TextObject::point(Point::ZERO, text, style); + object.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 100.0).to_path(0.1)) }; + let result = layout_with(db, &object, &LayoutOptions::default()); + + let next_cluster_byte = shaped.get(first_end).map(|g| g.byte); + assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte); +} + +/// No Break keeps the marked characters on one line, including across a space, and moves a +/// marked run that does not fit to the next line whole. +#[test] +fn no_break_keeps_marked_characters_on_one_line() { + let text = "A B"; + let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); + let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert!(!hyphen); + assert_eq!(end, 2, "without the mark the line ends after the space"); + + let mut marked = plain; + for g in &mut marked { + g.no_break = true; + } + let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert!(!hyphen); + assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); + + // 日 and 本 may break between them. Marked, they move together when they do not fit after "A ". + let text = "A 日本"; + let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); + glyphs[2].no_break = true; + glyphs[3].no_break = true; + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert_eq!(end, 2, "the marked pair moves to the next line together"); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); + + let breaks = candidates(text, &glyphs, None, Kinsoku::None); + assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); +}