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, + } + } +}