From 3d359c06c0d7004365069c003c4c577b14eff2eb Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 19 Oct 2026 23:31:34 +0200 Subject: [PATCH] :sparkles: Add CJK layout validation across box sizes Cover Chinese regional profiles and Korean wrap modes across fitting widths, style boundaries, punctuation, numeric units and decomposed Jamo. Check stable offsets and finite geometry in narrower horizontal boxes, and keep indivisible punctuation units together in short vertical boxes. AI-assisted-by: GPT-6 --- .../src/shapes/text_horizontal/tests.rs | 100 ++++++++++++++++++ .../src/shapes/text_vertical/layout.rs | 25 +++++ 2 files changed, 125 insertions(+) diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 31a3c32cec..d39bb3c9c6 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -143,6 +143,106 @@ fn emergency_word_boundaries_keep_identical_offsets_and_graphemes() { ); } +#[test] +fn cjk_emergency_wrapping_keeps_safe_boundaries_across_box_widths() { + use crate::shapes::text_policy::KoreanWrap; + use unicode_segmentation::UnicodeSegmentation; + + let cases: &[(&str, KoreanWrap, &[&str], &[f32])] = &[ + ( + "zh-Hans-CN", + KoreanWrap::Syllable, + &["文(", "中文)", ",中文"], + &[65.0, 72.0, 85.0, 102.0, 140.0], + ), + ( + "zh-Hant-TW", + KoreanWrap::Syllable, + &["文(", "中文)", ",中文"], + &[65.0, 72.0, 85.0, 102.0, 140.0], + ), + ( + "zh-Hant-HK", + KoreanWrap::Syllable, + &["文(", "中文)", ",中文"], + &[65.0, 72.0, 85.0, 102.0, 140.0], + ), + ( + "ko", + KoreanWrap::KeepWords, + &["한글 ", "한국어", "。"], + &[42.0, 52.0, 62.0, 72.0, 82.0, 100.0], + ), + ( + "ko", + KoreanWrap::Syllable, + &["한", "글(한", "글)와 1,234%"], + &[65.0, 72.0, 85.0, 102.0, 140.0], + ), + ( + "ko", + KoreanWrap::KeepWords, + &["한 ", "ᄒ", "ᅡᆫ글。"], + &[82.0, 100.0, 142.0], + ), + ]; + let mut violations = Vec::new(); + for &(language, wrap, texts, widths) in cases { + let source = texts.concat(); + let chars: Vec<_> = source.chars().collect(); + let boundaries: Vec<_> = source + .graphemes(true) + .scan(0, |at, g| { + let start = *at; + *at += g.chars().count(); + Some(start) + }) + .collect(); + for &width in widths { + let (paragraph, plan, layout) = cjk_horizontal_layout(texts, language, wrap, width); + for line in layout.get_line_metrics().into_iter().skip(1) { + let at = plan.offsets.builder_to_source(line.start_index); + if at < chars.len() + && (boundaries.binary_search(&at).is_err() + || (at > 0 + && !paragraph + .layout_policy() + .emergency_break_allowed(chars[at - 1], chars[at]))) + { + violations.push(format!( + "{language} {wrap:?} {width}px: break at {at} in {source}" + )); + } + } + } + } + assert!(violations.is_empty(), "{}", violations.join("\n")); +} + +#[test] +fn narrower_than_atomic_unit_layout_keeps_source_offsets_and_finite_geometry() { + use crate::shapes::text_policy::KoreanWrap; + // These widths cannot fit the punctuation unit. This checks data safety; + // it does not claim SkParagraph retains the policy's forbidden boundaries. + for language in ["zh-Hant-TW", "ko"] { + for width in [1.0, 10.0, 19.0, 20.0, 30.0, 39.0] { + let (paragraph, plan, layout) = + cjk_horizontal_layout(&["文", "。"], language, KoreanWrap::KeepWords, width); + let original = HorizontalParagraphPlan::new(¶graph); + for at in 0..=2 { + let builder = plan.offsets.source_to_builder(at); + assert_eq!(plan.offsets.builder_to_source(builder), at); + assert_eq!(builder, original.offsets.source_to_builder(at)); + } + assert!(layout.height().is_finite() && layout.height() > 0.0); + for line in layout.get_line_metrics() { + assert!(line.width.is_finite() && line.width >= 0.0); + assert!(line.baseline.is_finite()); + } + } + } +} + #[test] fn horizontal_cjk_justification_is_bounded_and_word_mode_only_expands_spaces() { use crate::shapes::text_policy::KoreanWrap; diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 4a16788364..164ca9b39a 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -844,6 +844,31 @@ mod tests { ) } + #[test] + fn boxes_shorter_than_atomic_units_keep_closing_punctuation_with_its_base() { + use crate::shapes::text_policy::KoreanWrap; + for (language, wrap) in [ + ("zh-Hans-CN", KoreanWrap::Syllable), + ("zh-Hant-TW", KoreanWrap::Syllable), + ("zh-Hant-HK", KoreanWrap::Syllable), + ("ko", KoreanWrap::Syllable), + ("ko", KoreanWrap::KeepWords), + ] { + for height in [1.0, 10.0, 19.0, 20.0, 30.0, 39.0] { + let layout = cjk_layout(&["文", "。"], language, wrap, TextAlign::Left, height); + assert_eq!(layout.cells.len(), 2); + assert_eq!( + layout.cells[0].column, layout.cells[1].column, + "{language} {wrap:?} {height}px: an indivisible unit overflows together" + ); + for cell in &layout.cells { + assert!(cell.top.is_finite()); + assert!(cell.extent.is_finite() && cell.extent > 0.0); + } + } + } + } + #[test] fn korean_emergency_wrap_keeps_closing_punctuation_with_its_base() { use crate::shapes::text_policy::KoreanWrap;