♻️ 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
This commit is contained in:
alonso.torres 2026-11-05 15:54:22 +01:00
parent 1b16d995ee
commit 2b888f712b
14 changed files with 353 additions and 215 deletions

View File

@ -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

View File

@ -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<String> = 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 {
&[]
}
}
}

View File

@ -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<String>, OffsetMap)> {
let jlreq = policy.uses_jlreq();
let source = span_texts.concat();
let boundaries: Vec<usize> = 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 {

View File

@ -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<Item = TextSpan>) {
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

View File

@ -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())
}
}

View File

@ -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<Option<Vec<usize>>> = paragraph
.children()
.iter()
@ -93,7 +92,7 @@ pub(crate) fn horizontal_pair_spacing(
paragraph: &Paragraph,
texts: &[String],
) -> Vec<Vec<(usize, f32)>> {
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

View File

@ -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;
}

View File

@ -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),

View File

@ -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<u8> 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('中', '文'));

View File

@ -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);

View File

@ -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 {

View File

@ -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<VerticalCell> {
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,

View File

@ -319,7 +319,7 @@ impl From<RawParagraph> 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
}
}

View File

@ -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<RawLineAdjustment> 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<RawKoreanWrap> for KoreanWrap {
fn from(value: RawKoreanWrap) -> Self {
match value {
RawKoreanWrap::Syllable => KoreanWrap::Syllable,
RawKoreanWrap::SpacesOnly => KoreanWrap::SpacesOnly,
RawKoreanWrap::KeepWords => KoreanWrap::KeepWords,
}
}
}