From 2b888f712b26c32aa5827f98a9762a9c4f250cee Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 5 Nov 2026 15:54:22 +0100 Subject: [PATCH] :recycle: Make the text layout policy one language decision TextLayoutPolicy had nine flat variants that mixed language, Chinese region and Korean wrap. It is now Plain, Japanese, Chinese(ChineseRegion) or Korean(KoreanWrap), and the layout code asks it named questions (uses_jlreq, plans_breaks, keeps_overlong_units, reopens_overlong_words, bounded_justify, needs_joiner, normalizes_western_spaces, uses_clreq_positions, ...) instead of comparing variants. Paragraph parses its language once, when the language or the Korean wrap changes, and copies the policy to its spans. Span code no longer builds a policy from the tag with a dummy Korean wrap, and the emphasis and underline positions take the policy instead of a Chinese flag. A LanguageTag parser now serves both the layout policy and the CJK font choice. It also accepts '_' as a separator, as the font choice did, so a tag such as "ja_JP" now selects the Japanese policy. The korean-wrap byte decodes through a new exported RawKoreanWrap enum (Syllable=0, SpacesOnly=1, KeepWords=2) with the same byte values. AI-assisted-by: claude-opus-5-5 --- render-wasm/docs/cjk-layout.md | 1 + render-wasm/src/render/fonts.rs | 36 +- render-wasm/src/shapes/line_breaks.rs | 26 +- render-wasm/src/shapes/text.rs | 73 ++-- render-wasm/src/shapes/text_attrs.rs | 13 +- render-wasm/src/shapes/text_horizontal/mod.rs | 13 +- .../src/shapes/text_horizontal/overflow.rs | 11 +- .../src/shapes/text_horizontal/tests.rs | 9 +- render-wasm/src/shapes/text_policy.rs | 338 ++++++++++++------ render-wasm/src/shapes/text_vertical/cells.rs | 4 +- render-wasm/src/shapes/text_vertical/flow.rs | 6 +- .../src/shapes/text_vertical/layout.rs | 15 +- render-wasm/src/wasm/text.rs | 2 +- render-wasm/src/wasm/text/layout_attrs.rs | 21 ++ 14 files changed, 353 insertions(+), 215 deletions(-) diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index 20ede9f7fb..75ba819465 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -139,6 +139,7 @@ style boundaries, decomposed Jamo, punctuation, justification, stable offsets and distinct wrap protocol bytes. Editor splits preserve the complete wrap mode. Both WASM consumers use the same writer and byte values: 0 syllable, 1 experiment, 2 keep-words; unknown values fall back to syllable wrapping. +The renderer decodes the byte through the exported `RawKoreanWrap` enum. Real Pinyin, Zhuyin and Korean IME checks and native-reader review remain release checks. Font glyph coverage and localized forms still depend on the diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 31f1dedb25..c17a9c92ef 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -2,6 +2,7 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr}; use std::collections::{BTreeSet, HashMap, HashSet}; use crate::error::{Error, Result}; +use crate::shapes::text_policy::LanguageTag; use crate::shapes::{FontFamily, FontStyle}; use crate::uuid::Uuid; @@ -47,24 +48,25 @@ impl CjkFace { /// tag) expects, best first. Without a CJK language, kana or hangul in /// `text` decide. fn preferred(language: &str, text: &str) -> &'static [Self] { - let mut subtags = language.split(['-', '_']).map(str::to_ascii_lowercase); - match subtags.next().as_deref() { - Some("ja") => &[Self::Japanese], - Some("ko") => &[Self::Korean], - Some("zh") => { - let subtags: Vec = subtags.collect(); - let has = |tag: &str| subtags.iter().any(|subtag| subtag == tag); - if has("hk") || has("mo") { - &[Self::HongKongChinese, Self::TraditionalChinese] - } else if has("tw") || has("hant") { - &[Self::TraditionalChinese, Self::HongKongChinese] - } else { - &[Self::SimplifiedChinese] - } + let tag = LanguageTag::parse(language); + if tag.is("ja") { + &[Self::Japanese] + } else if tag.is("ko") { + &[Self::Korean] + } else if tag.is("zh") { + if tag.has_region(&["HK", "MO"]) { + &[Self::HongKongChinese, Self::TraditionalChinese] + } else if tag.has_region(&["TW"]) || tag.has_script("Hant") { + &[Self::TraditionalChinese, Self::HongKongChinese] + } else { + &[Self::SimplifiedChinese] } - _ if text.chars().any(is_kana) => &[Self::Japanese], - _ if text.chars().any(is_hangul) => &[Self::Korean], - _ => &[], + } else if text.chars().any(is_kana) { + &[Self::Japanese] + } else if text.chars().any(is_hangul) { + &[Self::Korean] + } else { + &[] } } } diff --git a/render-wasm/src/shapes/line_breaks.rs b/render-wasm/src/shapes/line_breaks.rs index e12e886c86..79a1f5b005 100644 --- a/render-wasm/src/shapes/line_breaks.rs +++ b/render-wasm/src/shapes/line_breaks.rs @@ -38,7 +38,7 @@ pub const WESTERN_WORD_SPACE: char = '\u{2004}'; /// question or exclamation mark. pub const IDEOGRAPHIC_SPACE: char = '\u{3000}'; -use super::jlreq::{classify, keeps_together_classified, pair_rule, JlreqClass}; +use super::jlreq::{classify, pair_rule, JlreqClass}; use super::text_policy::TextLayoutPolicy; use unicode_segmentation::UnicodeSegmentation; @@ -119,6 +119,7 @@ pub fn apply_policy_with_breaks( emergency_breaks: &[usize], forced_breaks: &[usize], ) -> Option<(Vec, OffsetMap)> { + let jlreq = policy.uses_jlreq(); let source = span_texts.concat(); let boundaries: Vec = source.grapheme_indices(true).map(|(at, _)| at).collect(); let mut source_byte = 0; @@ -151,29 +152,18 @@ pub fn apply_policy_with_breaks( .get(span_index) .and_then(Option::as_ref) .is_some_and(|breaks| !breaks.contains(&local_utf16)); - let forbid_break = ruby_forbids_break - || prev.is_some_and(|p| { - if policy == TextLayoutPolicy::Japanese { - pair_rule(p.1, current.1).suppress_break_with_joiner - || keeps_together_classified(p, current) - } else { - !policy.break_allowed(p.0, c) - } - }); + let forbid_break = + ruby_forbids_break || prev.is_some_and(|p| policy.needs_joiner(p, current)); let mut insert = |ch: char| { shifted_text.push(ch); inserted.push(shifted_pos); shifted_pos += 1; changed = true; }; - if policy == TextLayoutPolicy::Japanese - && prev.is_some_and(|p| takes_em_space_after(p, current)) - { + if jlreq && prev.is_some_and(|p| takes_em_space_after(p, current)) { insert(IDEOGRAPHIC_SPACE); } - if policy == TextLayoutPolicy::Japanese - && prev.is_some_and(|p| is_japanese_western_boundary(p, current)) - { + if jlreq && prev.is_some_and(|p| is_japanese_western_boundary(p, current)) { insert(JAPANESE_WESTERN_SPACE); } // After an inserted space, the joiner keeps the break blocked. @@ -185,13 +175,13 @@ pub fn apply_policy_with_breaks( } else { WORD_JOINER }); - } else if policy == TextLayoutPolicy::Japanese + } else if jlreq && current.1 == JlreqClass::Inseparable && prev.is_some_and(|p| pair_rule(p.1, current.1).break_allowed) { insert(BREAK_OPPORTUNITY); } - let layout_char = if policy == TextLayoutPolicy::Japanese && c == ' ' { + let layout_char = if policy.normalizes_western_spaces() && c == ' ' { changed = true; WESTERN_WORD_SPACE } else { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 4c86b01f64..b77712f9c4 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -29,6 +29,7 @@ use unicode_segmentation::UnicodeSegmentation; use super::text_attrs::*; use super::text_horizontal::*; +use super::text_policy::{KoreanWrap, TextLayoutPolicy}; use super::{FontFamily, FontStyle}; use crate::math::Point; use crate::render::FallbackFonts; @@ -684,16 +685,10 @@ impl TextContent { if paragraph.text_align() == TextAlign::Distribute { return true; } - let policy = paragraph.layout_policy(); (uses_japanese_layout(paragraph, &plan.texts) && (self.line_adjustment == LineAdjustment::PushInFirst || paragraph.text_align() == TextAlign::Justify)) - || ((policy.is_chinese() - || matches!( - policy, - super::text_policy::TextLayoutPolicy::KoreanSyllable - | super::text_policy::TextLayoutPolicy::KoreanWords - )) + || (paragraph.layout_policy().bounded_justify() && paragraph.text_align() == TextAlign::Justify) } @@ -754,8 +749,7 @@ impl TextContent { laid_out.layout(plan.layout_width(width)); laid_out }; - let (sheds, laid_out) = if paragraph.layout_policy() - == super::text_policy::TextLayoutPolicy::Japanese + let (sheds, laid_out) = if paragraph.layout_policy().uses_jlreq() && self.line_adjustment == LineAdjustment::PushInFirst { horizontal_squeeze_sheds(paragraph, plan, width, &lay_out) @@ -767,7 +761,7 @@ impl TextContent { let spacing = if paragraph.text_align() == TextAlign::Distribute { horizontal_pairwise_justify_spacing(paragraph, &laid_out, plan, width) } else if paragraph.text_align() == TextAlign::Justify { - if paragraph.layout_policy() == super::text_policy::TextLayoutPolicy::Japanese { + if paragraph.layout_policy().uses_jlreq() { horizontal_jlreq_justify_spacing(&laid_out, plan, width) } else { horizontal_pairwise_justify_spacing(paragraph, &laid_out, plan, width) @@ -2148,7 +2142,10 @@ pub struct Paragraph { writing_mode: WritingMode, text_orientation: TextOrientation, language: String, - korean_wrap: super::text_policy::KoreanWrap, + korean_wrap: KoreanWrap, + /// Policy `language` and `korean_wrap` select, parsed once when either + /// changes and copied to every span. + layout_policy: TextLayoutPolicy, /// Paragraph in the content tree last exchanged with the editor host. source_position: u32, line_height: f32, @@ -2166,7 +2163,8 @@ impl Default for Paragraph { writing_mode: WritingMode::default(), text_orientation: TextOrientation::default(), language: String::new(), - korean_wrap: super::text_policy::KoreanWrap::Syllable, + korean_wrap: KoreanWrap::Syllable, + layout_policy: TextLayoutPolicy::Plain, source_position: u32::MAX, line_height: 1.0, letter_spacing: 0.0, @@ -2194,7 +2192,8 @@ impl Paragraph { writing_mode: WritingMode::default(), text_orientation: TextOrientation::default(), language: String::new(), - korean_wrap: super::text_policy::KoreanWrap::Syllable, + korean_wrap: KoreanWrap::Syllable, + layout_policy: TextLayoutPolicy::Plain, source_position: u32::MAX, line_height, letter_spacing, @@ -2211,25 +2210,28 @@ impl Paragraph { } pub fn set_language(&mut self, language: String) { - for span in &mut self.children { - span.language.clone_from(&language); - } self.language = language; + self.update_layout_policy(); } - pub fn korean_wrap(&self) -> super::text_policy::KoreanWrap { + pub fn korean_wrap(&self) -> KoreanWrap { self.korean_wrap } - pub fn set_korean_wrap(&mut self, value: super::text_policy::KoreanWrap) { + pub fn set_korean_wrap(&mut self, value: KoreanWrap) { self.korean_wrap = value; + self.update_layout_policy(); } - pub fn layout_policy(&self) -> super::text_policy::TextLayoutPolicy { - super::text_policy::TextLayoutPolicy::for_language_with_wrap( - &self.language, - self.korean_wrap, - ) + pub fn layout_policy(&self) -> TextLayoutPolicy { + self.layout_policy + } + + fn update_layout_policy(&mut self) { + self.layout_policy = TextLayoutPolicy::for_language(&self.language, self.korean_wrap); + for span in &mut self.children { + span.set_language(&self.language, self.layout_policy); + } } pub fn source_position(&self) -> u32 { @@ -2263,7 +2265,7 @@ impl Paragraph { /// Move spans into this paragraph, inheriting its shaping language. pub fn extend_children(&mut self, spans: impl IntoIterator) { self.children.extend(spans.into_iter().map(|mut span| { - span.language.clone_from(&self.language); + span.set_language(&self.language, self.layout_policy); span })); } @@ -2518,6 +2520,8 @@ pub struct TextSpan { pub text: String, /// Effective paragraph language, copied when the paragraph is decoded. pub language: String, + /// The paragraph's layout policy, copied with `language`. + pub layout_policy: TextLayoutPolicy, pub font_family: FontFamily, pub font_size: f32, pub line_height: f32, @@ -2554,6 +2558,7 @@ impl Default for TextSpan { Self { text: String::default(), language: String::new(), + layout_policy: TextLayoutPolicy::Plain, font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), font_size: 14.0, line_height: 1.2, @@ -2686,17 +2691,20 @@ impl TextSpan { + self.annotation_side_room_em(AnnotationSide::Under, false) } + /// Sets the shaping language and the layout policy of its paragraph. + pub fn set_language(&mut self, language: &str, policy: TextLayoutPolicy) { + self.language.clear(); + self.language.push_str(language); + self.layout_policy = policy; + } + pub fn emphasis_side(&self, vertical: bool) -> AnnotationSide { - self.text_emphasis_position.side( - vertical, - super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(), - ) + self.text_emphasis_position + .side(vertical, self.layout_policy) } pub fn underline_below_ink(&self) -> bool { - self.text_underline_position.below_ink( - super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(), - ) + self.text_underline_position.below_ink(self.layout_policy) } /// Only readings on the same side stack with emphasis marks. @@ -2718,8 +2726,7 @@ impl TextSpan { }; let positioned = self.text_underline_position != TextUnderlinePosition::Auto || self.text_emphasis_position != TextEmphasisPosition::Auto - || super::text_policy::TextLayoutPolicy::for_language(&self.language, false) - .is_chinese(); + || self.layout_policy.uses_clreq_positions(); if self.annotation_clearance.is_auto() && underline && positioned && side == underline_side { 0.18 diff --git a/render-wasm/src/shapes/text_attrs.rs b/render-wasm/src/shapes/text_attrs.rs index 25fff66752..804b9c21f2 100644 --- a/render-wasm/src/shapes/text_attrs.rs +++ b/render-wasm/src/shapes/text_attrs.rs @@ -4,6 +4,7 @@ //! ruby presentation enums. use crate::shapes::jlreq::classify; +use crate::shapes::text_policy::TextLayoutPolicy; pub const EMPHASIS_FONT_SCALE: f32 = 0.5; @@ -88,11 +89,13 @@ pub enum TextEmphasisPosition { } impl TextEmphasisPosition { - pub fn side(self, vertical: bool, chinese: bool) -> AnnotationSide { + /// Side of the marks; `Auto` takes the default of the paragraph policy. + pub fn side(self, vertical: bool, policy: TextLayoutPolicy) -> AnnotationSide { let under = if vertical { matches!(self, Self::OverLeft | Self::UnderLeft) } else { - matches!(self, Self::UnderRight | Self::UnderLeft) || (self == Self::Auto && chinese) + matches!(self, Self::UnderRight | Self::UnderLeft) + || (self == Self::Auto && policy.uses_clreq_positions()) }; if under { AnnotationSide::Under @@ -123,9 +126,11 @@ impl TextUnderlinePosition { } } - pub fn below_ink(self, chinese: bool) -> bool { + /// Whether a horizontal underline sits below the ink; `Auto` takes the + /// default of the paragraph policy. + pub fn below_ink(self, policy: TextLayoutPolicy) -> bool { matches!(self, Self::Under | Self::UnderLeft | Self::UnderRight) - || (self == Self::Auto && chinese) + || (self == Self::Auto && policy.uses_clreq_positions()) } } diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 3d11571a86..11d5c24a46 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -33,7 +33,6 @@ use crate::math::Point; use crate::shapes::jlreq::{ aki_class, is_cjk_text_char, pair_rule, punctuation_aki_sheds, AkiShed, JlreqClass, }; -use crate::shapes::text_policy::TextLayoutPolicy; use crate::shapes::{line_breaks, merge_fills}; use crate::utils::{get_fallback_fonts, get_font_collection}; use skia_safe::{ @@ -48,7 +47,7 @@ use skia_safe::{ /// True when a paragraph with layout texts `texts` needs the Japanese passes: /// its policy is Japanese and it has ruby or a CJK text character. pub(crate) fn uses_japanese_layout(paragraph: &Paragraph, texts: &[String]) -> bool { - paragraph.layout_policy() == TextLayoutPolicy::Japanese + paragraph.layout_policy().uses_jlreq() && (paragraph.children().iter().any(TextSpan::has_ruby) || texts.iter().any(|text| text.chars().any(is_cjk_text_char))) } @@ -70,7 +69,7 @@ fn layout_span_texts_with_breaks( .collect(); // Text without Japanese characters or ruby keeps plain Skia layout. let policy = paragraph.layout_policy(); - if uses_japanese_layout(paragraph, &texts) || policy.is_chinese() || policy.is_korean() { + if policy.plans_breaks() || uses_japanese_layout(paragraph, &texts) { let ruby_breaks: Vec>> = paragraph .children() .iter() @@ -93,7 +92,7 @@ pub(crate) fn horizontal_pair_spacing( paragraph: &Paragraph, texts: &[String], ) -> Vec> { - if paragraph.layout_policy() != TextLayoutPolicy::Japanese { + if !paragraph.layout_policy().uses_jlreq() { return horizontal_pairwise_spacing(paragraph, texts); } jlreq_aki_sheds(paragraph, texts) @@ -368,11 +367,7 @@ impl HorizontalParagraphPlan { ) { use unicode_segmentation::UnicodeSegmentation; let policy = paragraph.layout_policy(); - if !(policy.is_chinese() - || matches!( - policy, - TextLayoutPolicy::KoreanWords | TextLayoutPolicy::KoreanSyllable - )) + if !policy.reopens_overlong_words() || !width.is_finite() || width <= 0.0 || width == f32::MAX diff --git a/render-wasm/src/shapes/text_horizontal/overflow.rs b/render-wasm/src/shapes/text_horizontal/overflow.rs index d06a80f413..106f114b26 100644 --- a/render-wasm/src/shapes/text_horizontal/overflow.rs +++ b/render-wasm/src/shapes/text_horizontal/overflow.rs @@ -16,16 +16,7 @@ impl HorizontalParagraphPlan { ) { use unicode_segmentation::UnicodeSegmentation; let policy = paragraph.layout_policy(); - if !(policy.is_chinese() - || matches!( - policy, - TextLayoutPolicy::Japanese - | TextLayoutPolicy::KoreanWords - | TextLayoutPolicy::KoreanSyllable - )) - || !width.is_finite() - || width <= 0.0 - || width == f32::MAX + if !policy.keeps_overlong_units() || !width.is_finite() || width <= 0.0 || width == f32::MAX { return; } diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 3fb3f8cdfb..5910074e7d 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -5,6 +5,7 @@ use super::ruby::{ }; use super::*; use crate::shapes::text_paragraph::LayoutParagraph; +use crate::shapes::text_policy::TextLayoutPolicy; use crate::shapes::{TextAlign, TextDirection, TextTransform}; use crate::Uuid; @@ -1419,7 +1420,10 @@ fn emphasis_position_overrides_the_language_and_stacks_on_its_own_side() { ("ja", TextEmphasisPosition::UnderRight, true), ] { let mut span = make_span("中文", 0.0); - span.language = language.to_string(); + span.set_language( + language, + TextLayoutPolicy::for_language(language, Default::default()), + ); span.text_emphasis = TextEmphasis::FilledDot; span.text_emphasis_position = position; span.annotation_clearance = AnnotationClearance::Auto; @@ -1509,6 +1513,7 @@ fn cjk_underline_takes_the_paint_of_its_own_span() { fn below_emphasis_clears_an_underline_on_the_same_side() { let span = TextSpan { language: "zh-Hans".to_string(), + layout_policy: TextLayoutPolicy::for_language("zh-Hans", Default::default()), text_emphasis: TextEmphasis::FilledDot, text_decoration: Some(skia::textlayout::TextDecoration::UNDERLINE), text_underline_position: TextUnderlinePosition::Under, @@ -1530,6 +1535,7 @@ fn below_emphasis_clears_an_underline_on_the_same_side() { fn chinese_horizontal_emphasis_defaults_below_the_base() { let span = TextSpan { language: "zh-Hans-CN".to_string(), + layout_policy: TextLayoutPolicy::for_language("zh-Hans-CN", Default::default()), text_emphasis: TextEmphasis::FilledDot, ..make_span("中文", 0.0) }; @@ -2231,6 +2237,7 @@ fn horizontal_emphasis_export_bounds_include_every_adjacent_ruby_mark() { .map(|text| TextSpan { font_size: 48.0, language: "zh-Hant-TW".to_string(), + layout_policy: TextLayoutPolicy::for_language("zh-Hant-TW", Default::default()), ruby: "a".to_string(), text_emphasis: TextEmphasis::FilledDot, text_decoration: Some(skia::textlayout::TextDecoration::UNDERLINE), diff --git a/render-wasm/src/shapes/text_policy.rs b/render-wasm/src/shapes/text_policy.rs index b9c3bb2e2c..76dfbf883e 100644 --- a/render-wasm/src/shapes/text_policy.rs +++ b/render-wasm/src/shapes/text_policy.rs @@ -2,7 +2,10 @@ //! Regional presets use CLREQ (2026-10-07); Korean restrictions use KLREQ //! (2026-03-21). These are named presets, not universal regional mandates. -use super::jlreq::break_allowed_between; +use super::jlreq::{ + break_allowed_between, forbidden_at_line_end, forbidden_at_line_start, + keeps_together_classified, pair_rule, JlreqClass, +}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -14,14 +17,18 @@ pub enum KoreanWrap { KeepWords = 2, } -impl From for KoreanWrap { - fn from(value: u8) -> Self { - match value { - 1 => Self::SpacesOnly, - 2 => Self::KeepWords, - _ => Self::Syllable, - } - } +/// Regional Chinese preset, chosen by the region subtag only. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ChineseRegion { + /// Original basic preset, retained for tags without a known region. + #[default] + Basic, + /// CN and SG. + Mainland, + /// TW. + Taiwan, + /// HK and MO. + HongKong, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -29,14 +36,61 @@ pub enum TextLayoutPolicy { #[default] Plain, Japanese, - /// Original basic preset, retained for tags without a region. - Chinese, - ChineseMainland, - ChineseTaiwan, - ChineseHongKong, - KoreanSyllable, - KoreanSpacesOnly, - KoreanWords, + Chinese(ChineseRegion), + Korean(KoreanWrap), +} + +/// The parts of a BCP 47 language tag the layout and font choices read. +/// Subtags may be separated by `-` or `_`; matching ignores case. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct LanguageTag<'a> { + pub primary: &'a str, + /// Four-letter script subtag (`Hans`, `Hant`). + pub script: Option<&'a str>, + /// Two-letter or three-digit region subtag (`CN`, `TW`, `419`). + pub region: Option<&'a str>, +} + +impl<'a> LanguageTag<'a> { + pub fn parse(tag: &'a str) -> Self { + let mut subtags = tag.split(['-', '_']); + let primary = subtags.next().unwrap_or_default(); + let mut script = None; + let mut region = None; + // Script and region precede extensions and private use, which start + // at a singleton subtag. + for subtag in subtags.take_while(|subtag| subtag.len() != 1) { + if script.is_none() + && subtag.len() == 4 + && subtag.bytes().all(|b| b.is_ascii_alphabetic()) + { + script = Some(subtag); + } else if region.is_none() + && (subtag.len() == 2 + || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))) + { + region = Some(subtag); + } + } + Self { + primary, + script, + region, + } + } + + pub fn is(&self, primary: &str) -> bool { + self.primary.eq_ignore_ascii_case(primary) + } + + pub fn has_script(&self, script: &str) -> bool { + self.script.is_some_and(|s| s.eq_ignore_ascii_case(script)) + } + + pub fn has_region(&self, regions: &[&str]) -> bool { + self.region + .is_some_and(|r| regions.iter().any(|region| r.eq_ignore_ascii_case(region))) + } } pub fn is_han(ch: char) -> bool { @@ -63,78 +117,112 @@ fn protected(ch: char) -> bool { } impl TextLayoutPolicy { - pub fn for_language(language: &str, korean_spaces_only: bool) -> Self { - Self::for_language_with_wrap( - language, - if korean_spaces_only { - KoreanWrap::SpacesOnly - } else { - KoreanWrap::Syllable - }, - ) - } - - pub fn for_language_with_wrap(language: &str, wrap: KoreanWrap) -> Self { - let mut parts = language.split('-'); - let primary = parts.next().unwrap_or_default(); - if primary.eq_ignore_ascii_case("ja") { + /// The policy a paragraph language (a BCP 47 tag) selects. Script never + /// implies region. `korean_wrap` applies only to Korean. + pub fn for_language(language: &str, korean_wrap: KoreanWrap) -> Self { + let tag = LanguageTag::parse(language); + if tag.is("ja") { Self::Japanese - } else if primary.eq_ignore_ascii_case("zh") { - // Region precedes extensions/private use; script never implies region. - let region = parts.take_while(|part| part.len() != 1).find(|part| { - part.len() == 2 || (part.len() == 3 && part.bytes().all(|b| b.is_ascii_digit())) - }); - match region { - Some(region) - if region.eq_ignore_ascii_case("CN") || region.eq_ignore_ascii_case("SG") => - { - Self::ChineseMainland - } - Some(region) if region.eq_ignore_ascii_case("TW") => Self::ChineseTaiwan, - Some(region) - if region.eq_ignore_ascii_case("HK") || region.eq_ignore_ascii_case("MO") => - { - Self::ChineseHongKong - } - _ => Self::Chinese, - } - } else if primary.eq_ignore_ascii_case("ko") { - match wrap { - KoreanWrap::Syllable => Self::KoreanSyllable, - KoreanWrap::SpacesOnly => Self::KoreanSpacesOnly, - KoreanWrap::KeepWords => Self::KoreanWords, - } + } else if tag.is("zh") { + Self::Chinese(if tag.has_region(&["CN", "SG"]) { + ChineseRegion::Mainland + } else if tag.has_region(&["TW"]) { + ChineseRegion::Taiwan + } else if tag.has_region(&["HK", "MO"]) { + ChineseRegion::HongKong + } else { + ChineseRegion::Basic + }) + } else if tag.is("ko") { + Self::Korean(korean_wrap) } else { Self::Plain } } pub fn is_chinese(self) -> bool { - matches!( - self, - Self::Chinese | Self::ChineseMainland | Self::ChineseTaiwan | Self::ChineseHongKong - ) - } - - pub fn is_regional_chinese(self) -> bool { - matches!( - self, - Self::ChineseMainland | Self::ChineseTaiwan | Self::ChineseHongKong - ) + matches!(self, Self::Chinese(_)) } pub fn is_korean(self) -> bool { + matches!(self, Self::Korean(_)) + } + + /// Japanese layout: JLREQ classes, pair rules, kinsoku, aki and the + /// squeeze/justify passes built on them. + pub fn uses_jlreq(self) -> bool { + self == Self::Japanese + } + + /// Horizontal layout texts always carry this policy's break rules. + /// Japanese plans them only for text with ruby or CJK characters + /// (`uses_japanese_layout`); plain text keeps Skia's breaks. + pub fn plans_breaks(self) -> bool { + matches!(self, Self::Chinese(_) | Self::Korean(_)) + } + + /// An indivisible unit wider than the box overflows whole instead of + /// taking Skia's emergency split. The legacy Korean spaces-only mode + /// keeps its earlier behaviour. + pub fn keeps_overlong_units(self) -> bool { + self == Self::Japanese || self.reopens_overlong_words() + } + + /// An overlong word may wrap at safe grapheme boundaries the policy's + /// emergency rules allow. + pub fn reopens_overlong_words(self) -> bool { matches!( self, - Self::KoreanSyllable | Self::KoreanSpacesOnly | Self::KoreanWords + Self::Chinese(_) | Self::Korean(KoreanWrap::Syllable | KoreanWrap::KeepWords) ) } + /// Justification expands the policy's pairwise opportunities by bounded + /// amounts (`expansion_em`) instead of leaving it to Skia. + pub fn bounded_justify(self) -> bool { + self.reopens_overlong_words() + } + + /// The legacy Korean spaces-only mode never justified vertical columns. + pub fn justifies_columns(self) -> bool { + self != Self::Korean(KoreanWrap::SpacesOnly) + } + + /// Whether a vertical column break measures the whole unit that would + /// move to the next column, or (legacy spaces-only) only the part up to + /// the overflowing cell. + pub fn measures_whole_unit(self) -> bool { + self != Self::Korean(KoreanWrap::SpacesOnly) + } + + /// Western word spaces set to one third em (JLREQ §3.2.6). + pub fn normalizes_western_spaces(self) -> bool { + self == Self::Japanese + } + + /// Automatic emphasis and underline positions follow CLREQ: emphasis + /// below horizontal text, underlines below the ink. + pub fn uses_clreq_positions(self) -> bool { + self.is_chinese() + } + + /// Whether horizontal layout text needs a WORD JOINER between two + /// adjacent grapheme bases, given with their JLREQ classes, to block a + /// break Skia would otherwise take. + pub fn needs_joiner(self, before: (char, JlreqClass), after: (char, JlreqClass)) -> bool { + if self.uses_jlreq() { + pair_rule(before.1, after.1).suppress_break_with_joiner + || keeps_together_classified(before, after) + } else { + !self.break_allowed(before.0, after.0) + } + } + /// Even emergency wrapping must retain punctuation, nonbreaking controls, /// numeric expressions and paired marks. Grapheme/annotation atomicity is /// enforced by the caller on the complete paragraph, across style runs. pub fn emergency_break_allowed(self, before: char, after: char) -> bool { - if matches!(self, Self::Plain | Self::KoreanSpacesOnly) { + if matches!(self, Self::Plain | Self::Korean(KoreanWrap::SpacesOnly)) { return true; } !protected(before) @@ -142,9 +230,8 @@ impl TextLayoutPolicy { && !opening(before) && !closing(after) && !(self.is_korean() && "‐‑–-・·々〻ー".contains(after)) - && !(self == Self::Japanese - && (crate::shapes::jlreq::forbidden_at_line_start(after) - || crate::shapes::jlreq::forbidden_at_line_end(before))) + && !(self.uses_jlreq() + && (forbidden_at_line_start(after) || forbidden_at_line_end(before))) && !(before == after && matches!(after, '…' | '‥' | '—' | '―')) && !("$$£€₩¥¥₽₺₫++--−".contains(before) && numeric(after)) && !(numeric(before) && "%%‰℃℉°".contains(after)) @@ -156,12 +243,12 @@ impl TextLayoutPolicy { pub fn break_allowed(self, before: char, after: char) -> bool { match self { Self::Japanese => break_allowed_between(before, after), - Self::KoreanSpacesOnly => before.is_whitespace() || after.is_whitespace(), + Self::Korean(KoreanWrap::SpacesOnly) => before.is_whitespace() || after.is_whitespace(), Self::Plain => true, - _ => { + Self::Chinese(_) | Self::Korean(_) => { self.emergency_break_allowed(before, after) && !(before.is_ascii_alphanumeric() && after.is_ascii_alphanumeric()) - && !(self == Self::KoreanWords + && !(self == Self::Korean(KoreanWrap::KeepWords) && before.is_alphanumeric() && after.is_alphanumeric()) } @@ -172,9 +259,10 @@ impl TextLayoutPolicy { /// TW uses a solid preset. CN/HK compress only adjacent punctuation; fixed /// marks (notably vertical colon/semicolon/question/exclamation) keep 1em. pub fn punctuation_space(self, ch: char, vertical: bool) -> (f32, f32) { - if !matches!(self, Self::ChineseMainland | Self::ChineseHongKong) { + let Self::Chinese(region @ (ChineseRegion::Mainland | ChineseRegion::HongKong)) = self + else { return (0.0, 0.0); - } + }; if "([{〈《「『【〔〖〘〚".contains(ch) { return (0.5, 0.0); } @@ -182,12 +270,12 @@ impl TextLayoutPolicy { return (0.0, 0.5); } let adjustable = ",。、.".contains(ch) - || (!vertical && self == Self::ChineseMainland && ":;!?".contains(ch)) - || (!vertical && self == Self::ChineseHongKong && ":;".contains(ch)); + || (!vertical && region == ChineseRegion::Mainland && ":;!?".contains(ch)) + || (!vertical && region == ChineseRegion::HongKong && ":;".contains(ch)); if !adjustable { return (0.0, 0.0); } - if self == Self::ChineseMainland { + if region == ChineseRegion::Mainland { (0.0, 0.5) } else { (0.25, 0.25) @@ -211,7 +299,10 @@ impl TextLayoutPolicy { /// compression and push-out; no preset silently enables horizontal hanging. pub fn hanging_em(self, ch: char, vertical: bool) -> f32 { if vertical - && matches!(self, Self::ChineseTaiwan | Self::ChineseHongKong) + && matches!( + self, + Self::Chinese(ChineseRegion::Taiwan | ChineseRegion::HongKong) + ) && ",。、.".contains(ch) { 0.5 @@ -221,7 +312,8 @@ impl TextLayoutPolicy { } pub fn script_spacing_em(self, before: char, after: char) -> f32 { - if self.is_regional_chinese() + let regional = matches!(self, Self::Chinese(region) if region != ChineseRegion::Basic); + if regional && ((is_han(before) && after.is_ascii_alphanumeric()) || (before.is_ascii_alphanumeric() && is_han(after))) { @@ -247,7 +339,10 @@ impl TextLayoutPolicy { { return (1, 0.5); } - if self == Self::KoreanSyllable && before.is_alphanumeric() && after.is_alphanumeric() { + if self == Self::Korean(KoreanWrap::Syllable) + && before.is_alphanumeric() + && after.is_alphanumeric() + { return (2, 0.125); } (0, 0.0) @@ -264,40 +359,67 @@ mod tests { ("", TextLayoutPolicy::Plain), ("en", TextLayoutPolicy::Plain), ("ja-JP", TextLayoutPolicy::Japanese), - ("ZH-Hant-TW", TextLayoutPolicy::ChineseTaiwan), - ("ko", TextLayoutPolicy::KoreanSyllable), + ( + "ZH-Hant-TW", + TextLayoutPolicy::Chinese(ChineseRegion::Taiwan), + ), + ("ko", TextLayoutPolicy::Korean(KoreanWrap::Syllable)), ] { - assert_eq!(TextLayoutPolicy::for_language(language, false), expected); + assert_eq!( + TextLayoutPolicy::for_language(language, KoreanWrap::Syllable), + expected + ); } assert_eq!( - TextLayoutPolicy::for_language("ko-KR", true), - TextLayoutPolicy::KoreanSpacesOnly + TextLayoutPolicy::for_language("ko-KR", KoreanWrap::SpacesOnly), + TextLayoutPolicy::Korean(KoreanWrap::SpacesOnly) ); assert_eq!( - TextLayoutPolicy::for_language("zh", true), - TextLayoutPolicy::Chinese + TextLayoutPolicy::for_language("zh", KoreanWrap::SpacesOnly), + TextLayoutPolicy::Chinese(ChineseRegion::Basic) ); } #[test] fn chinese_regions_are_explicit_and_independent_of_script() { - let mainland = TextLayoutPolicy::for_language("zh-Hans-CN", false); - let mainland_traditional = TextLayoutPolicy::for_language("zh-Hant-CN", false); - let taiwan = TextLayoutPolicy::for_language("zh-Hant-TW", false); - let hong_kong = TextLayoutPolicy::for_language("zh-Hant-HK", false); + let tag = LanguageTag::parse("zh-Hant-TW-x-hk"); + assert!(tag.is("ZH")); + assert!(tag.has_script("hant")); + assert!(tag.has_region(&["tw"])); + assert!(!tag.has_region(&["HK"])); + assert_eq!(LanguageTag::parse("es-419").region, Some("419")); + for (language, expected) in [ + ("zh-Hant-HK", ChineseRegion::HongKong), + ("zh-MO", ChineseRegion::HongKong), + ("zh-SG", ChineseRegion::Mainland), + ] { + assert_eq!( + TextLayoutPolicy::for_language(language, KoreanWrap::Syllable), + TextLayoutPolicy::Chinese(expected), + "{language}" + ); + } + let mainland = TextLayoutPolicy::for_language("zh-Hans-CN", KoreanWrap::Syllable); + let mainland_traditional = + TextLayoutPolicy::for_language("zh-Hant-CN", KoreanWrap::Syllable); + let taiwan = TextLayoutPolicy::for_language("zh-Hant-TW", KoreanWrap::Syllable); + let hong_kong = TextLayoutPolicy::for_language("zh-Hant-HK", KoreanWrap::Syllable); assert_eq!(mainland, mainland_traditional); assert_ne!(mainland, taiwan); assert_ne!(taiwan, hong_kong); - assert_eq!(mainland, TextLayoutPolicy::for_language("ZH-cn", false)); assert_eq!( - TextLayoutPolicy::Chinese, - TextLayoutPolicy::for_language("zh-Hant", false) + mainland, + TextLayoutPolicy::for_language("ZH-cn", KoreanWrap::Syllable) + ); + assert_eq!( + TextLayoutPolicy::Chinese(ChineseRegion::Basic), + TextLayoutPolicy::for_language("zh-Hant", KoreanWrap::Syllable) ); } #[test] fn korean_punctuation_and_numeric_units_stay_attached() { - let policy = TextLayoutPolicy::KoreanSyllable; + let policy = TextLayoutPolicy::Korean(KoreanWrap::Syllable); for (before, after) in [ ('한', '-'), ('한', '·'), @@ -323,7 +445,7 @@ mod tests { #[test] fn word_mode_retains_punctuation_opportunities_and_blocks_letter_pairs() { - let policy = TextLayoutPolicy::for_language_with_wrap("ko-KR", KoreanWrap::KeepWords); + let policy = TextLayoutPolicy::for_language("ko-KR", KoreanWrap::KeepWords); for (before, after) in [ ('한', '글'), ('漢', '字'), @@ -346,14 +468,14 @@ mod tests { for (before, after) in [('(', '한'), ('글', ')'), ('한', ','), ('글', '\u{00a0}')] { assert!(!policy.emergency_break_allowed(before, after)); } - assert!(!TextLayoutPolicy::KoreanSpacesOnly.break_allowed(')', '한')); + assert!(!TextLayoutPolicy::Korean(KoreanWrap::SpacesOnly).break_allowed(')', '한')); } #[test] fn regional_fitting_uses_its_own_spacing_and_orientation_rules() { - let cn = TextLayoutPolicy::ChineseMainland; - let tw = TextLayoutPolicy::ChineseTaiwan; - let hk = TextLayoutPolicy::ChineseHongKong; + let cn = TextLayoutPolicy::Chinese(ChineseRegion::Mainland); + let tw = TextLayoutPolicy::Chinese(ChineseRegion::Taiwan); + let hk = TextLayoutPolicy::Chinese(ChineseRegion::HongKong); assert_eq!(cn.compression_em(',', '」', false), 0.5); assert_eq!(tw.compression_em(',', '」', false), 0.0); assert_eq!(hk.compression_em(',', '」', false), 0.25); @@ -364,22 +486,22 @@ mod tests { assert_eq!(cn.script_spacing_em('中', 'A'), 0.25); assert_eq!(cn.script_spacing_em(',', 'A'), 0.0); assert_eq!( - TextLayoutPolicy::KoreanWords.script_spacing_em('漢', 'A'), + TextLayoutPolicy::Korean(KoreanWrap::KeepWords).script_spacing_em('漢', 'A'), 0.0 ); assert_eq!( - TextLayoutPolicy::KoreanWords.expansion_em('한', '글'), + TextLayoutPolicy::Korean(KoreanWrap::KeepWords).expansion_em('한', '글'), (0, 0.0) ); assert_eq!( - TextLayoutPolicy::KoreanWords.expansion_em(' ', '한'), + TextLayoutPolicy::Korean(KoreanWrap::KeepWords).expansion_em(' ', '한'), (1, 0.5) ); } #[test] fn chinese_basic_restrictions_do_not_include_small_kana() { - let policy = TextLayoutPolicy::Chinese; + let policy = TextLayoutPolicy::Chinese(ChineseRegion::Basic); assert!(!policy.break_allowed('「', '中')); assert!(!policy.break_allowed('文', '」')); assert!(policy.break_allowed('中', '文')); diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index f0f70937e1..dd890557fc 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -453,9 +453,7 @@ impl<'a> SpanCells<'a> { run_index: usize, ) -> FlowCell { let letter_spacing = self.span.letter_spacing; - if crate::shapes::text_policy::TextLayoutPolicy::for_language(&self.span.language, false) - == crate::shapes::text_policy::TextLayoutPolicy::Japanese - { + if self.span.layout_policy.normalizes_western_spaces() { run.normalize_word_spaces(&segment.text, self.span.font_size); } let extent = run.spread_glyphs(letter_spacing); diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index df571875d0..8addeea3a0 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -354,10 +354,10 @@ pub(super) fn plan_pairwise_columns( }; if cursor > 0.0 && cursor + flow.cell.extent - hang > max_height + FIT_TOLERANCE { let candidate = unit_start[i]; - let end = if policy == TextLayoutPolicy::KoreanSpacesOnly { - i + 1 - } else { + let end = if policy.measures_whole_unit() { unit_end[i] + } else { + i + 1 }; let fits_empty = prefix[end] - prefix[candidate] <= max_height + FIT_TOLERANCE; let break_at = if candidate > column_start && fits_empty { diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 96e816714b..be3021f6bc 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -568,7 +568,7 @@ impl PlannedParagraph { let mut ruby_units = ruby_units.clone(); let policy = cells.paragraph.layout_policy(); - if policy != TextLayoutPolicy::Japanese { + if !policy.uses_jlreq() { grow_ruby_bases(&mut flow, &ruby_units); let text: String = cells .text_map @@ -641,13 +641,12 @@ impl PlannedParagraph { /// Final cells, aligned along their columns, numbered from `column_base`. /// A later span's cell of a warichu piece takes the piece's box. fn place(&self, align: TextAlign, max_height: f32, column_base: usize) -> Vec { - let align = - if self.policy == TextLayoutPolicy::KoreanSpacesOnly && align == TextAlign::Justify { - TextAlign::Left - } else { - align - }; - let tops = if self.policy == TextLayoutPolicy::Japanese && align != TextAlign::Distribute { + let align = if !self.policy.justifies_columns() && align == TextAlign::Justify { + TextAlign::Left + } else { + align + }; + let tops = if self.policy.uses_jlreq() && align != TextAlign::Distribute { jlreq_aligned_tops( &self.flow, &self.classes, diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 0ae5d3fe2f..db8c3f8a45 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -319,7 +319,7 @@ impl From for shapes::Paragraph { .and_then(|end| value.text_buffer.get(ruby_offset..end)) .unwrap_or(&[]); paragraph.set_language(String::from_utf8_lossy(language).into_owned()); - paragraph.set_korean_wrap(value.attrs.korean_wrap.into()); + paragraph.set_korean_wrap(RawKoreanWrap::from(value.attrs.korean_wrap).into()); paragraph } } diff --git a/render-wasm/src/wasm/text/layout_attrs.rs b/render-wasm/src/wasm/text/layout_attrs.rs index 65027208fe..8b6316a713 100644 --- a/render-wasm/src/wasm/text/layout_attrs.rs +++ b/render-wasm/src/wasm/text/layout_attrs.rs @@ -1,3 +1,4 @@ +use crate::shapes::text_policy::KoreanWrap; use crate::shapes::{ self, AnnotationClearance, AnnotationSide, FontFeatures, LineAdjustment, RubyAlign, RubyOverhang, RubySize, TextCombineUpright, TextEmphasis, @@ -342,3 +343,23 @@ impl From for LineAdjustment { } } } + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawKoreanWrap { + Syllable = 0, + SpacesOnly = 1, + KeepWords = 2, +} + +raw_enum_from_byte!(RawKoreanWrap, Syllable, [Syllable, SpacesOnly, KeepWords]); + +impl From for KoreanWrap { + fn from(value: RawKoreanWrap) -> Self { + match value { + RawKoreanWrap::Syllable => KoreanWrap::Syllable, + RawKoreanWrap::SpacesOnly => KoreanWrap::SpacesOnly, + RawKoreanWrap::KeepWords => KoreanWrap::KeepWords, + } + } +}