From 1b16d995eed200b45f6facc552012ffffa63b305 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 5 Nov 2026 15:49:19 +0100 Subject: [PATCH] :recycle: Rename CJK text modules and helpers in render-wasm The CJK layout code kept names from when it only handled Japanese. Chinese regional presets, Pinyin, Zhuyin and Korean now share it, so rename the modules and helpers to say what they do: - kinsoku.rs becomes line_breaks.rs: the layout-text rewrite with OffsetMap and the inserted break markers. The kinsoku predicates move to jlreq.rs (was japanese.rs); JapaneseClass becomes JlreqClass. - is_japanese_text_char becomes is_cjk_text_char. - text_japanese.rs splits into text_attrs.rs (span attribute enums), text_ruby.rs (ruby pieces and overhang), text_warichu.rs and text_allocation.rs (capped_equal_allocations, shared by both axes). - wasm/text/japanese_layout.rs becomes layout_attrs.rs. - RubySide becomes AnnotationSide; RawTextSpan.ruby_side becomes ruby_flags, since the byte packs the side and the Zhuyin bit. - horizontal_aki_sheds becomes the horizontal_pair_spacing dispatcher with a jlreq_aki_sheds branch; the plan field sheds is pair_spacing. - The policy_* helpers become pairwise_* and the Japanese ones jlreq_*. - The oikomi policy parameter is adjustment and minimum_oikomi_extent is min_squeezed_extent. Exported functions, Raw* enums and byte layouts stay unchanged. AI-assisted-by: claude-opus-5-5 --- render-wasm/src/render/text.rs | 2 +- render-wasm/src/render/text_editor.rs | 2 +- render-wasm/src/shapes.rs | 11 +- .../src/shapes/{japanese.rs => jlreq.rs} | 413 +++++++++--------- .../src/shapes/{kinsoku.rs => line_breaks.rs} | 69 +-- render-wasm/src/shapes/text.rs | 38 +- render-wasm/src/shapes/text_allocation.rs | 40 ++ render-wasm/src/shapes/text_attrs.rs | 251 +++++++++++ .../src/shapes/text_horizontal/emphasis.rs | 4 +- .../src/shapes/text_horizontal/justify.rs | 33 +- render-wasm/src/shapes/text_horizontal/mod.rs | 80 ++-- .../src/shapes/text_horizontal/offsets.rs | 10 +- .../src/shapes/text_horizontal/ruby.rs | 11 +- .../src/shapes/text_horizontal/tests.rs | 64 +-- render-wasm/src/shapes/text_policy.rs | 6 +- .../shapes/{text_japanese.rs => text_ruby.rs} | 343 +-------------- .../src/shapes/text_vertical/annotations.rs | 45 +- render-wasm/src/shapes/text_vertical/cells.rs | 16 +- render-wasm/src/shapes/text_vertical/flow.rs | 139 +++--- .../src/shapes/text_vertical/layout.rs | 44 +- render-wasm/src/shapes/text_vertical/paint.rs | 2 +- render-wasm/src/shapes/text_warichu.rs | 53 +++ render-wasm/src/wasm/text.rs | 27 +- .../{japanese_layout.rs => layout_attrs.rs} | 10 +- 24 files changed, 874 insertions(+), 839 deletions(-) rename render-wasm/src/shapes/{japanese.rs => jlreq.rs} (66%) rename render-wasm/src/shapes/{kinsoku.rs => line_breaks.rs} (93%) create mode 100644 render-wasm/src/shapes/text_allocation.rs create mode 100644 render-wasm/src/shapes/text_attrs.rs rename render-wasm/src/shapes/{text_japanese.rs => text_ruby.rs} (52%) create mode 100644 render-wasm/src/shapes/text_warichu.rs rename render-wasm/src/wasm/text/{japanese_layout.rs => layout_attrs.rs} (96%) diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 1301ddfdbc..fd11d5b892 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -188,7 +188,7 @@ pub fn stroke_paragraph_builder_group_from_text( .children() .iter() .zip(&plan.texts) - .zip(&plan.sheds) + .zip(&plan.pair_spacing) .zip(&plan.ruby_spacing.adjustments) .enumerate() { diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index af6d7ca9d7..61d3bd9583 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -756,7 +756,7 @@ mod tests { assert!(content.horizontal_plans().iter().all(|plan| !plan .texts .concat() - .contains(crate::shapes::kinsoku::FORCED_LINE_BREAK))); + .contains(crate::shapes::line_breaks::FORCED_LINE_BREAK))); content.update_layout(shape.selrect); assert_eq!( content diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index ab30257a6a..bcbb2247cc 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -17,9 +17,9 @@ mod fonts; mod frames; mod gpos_vpal; mod groups; -pub mod japanese; -pub mod kinsoku; +pub mod jlreq; mod layouts; +pub mod line_breaks; pub mod modifiers; mod paths; mod rects; @@ -30,12 +30,15 @@ mod strokes; mod svg_attrs; mod svgraw; mod text; +mod text_allocation; +mod text_attrs; mod text_horizontal; -mod text_japanese; pub mod text_paragraph; pub mod text_paths; pub mod text_policy; +mod text_ruby; pub mod text_vertical; +mod text_warichu; mod text_zhuyin; mod transform; @@ -58,8 +61,8 @@ pub use strokes::*; pub use svg_attrs::*; pub use svgraw::*; pub use text::*; +pub use text_attrs::*; pub(crate) use text_horizontal::*; -pub use text_japanese::*; pub use transform::*; use crate::math::{self, Bounds, Matrix, Point}; diff --git a/render-wasm/src/shapes/japanese.rs b/render-wasm/src/shapes/jlreq.rs similarity index 66% rename from render-wasm/src/shapes/japanese.rs rename to render-wasm/src/shapes/jlreq.rs index 1901cbf7c0..5feba8e280 100644 --- a/render-wasm/src/shapes/japanese.rs +++ b/render-wasm/src/shapes/jlreq.rs @@ -1,4 +1,5 @@ -//! Shared JLREQ character classes and pair-rule tables. +//! JLREQ (Japanese) character classes, pair-rule tables and kinsoku rules, +//! shared by the horizontal and vertical engines. //! //! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are //! virtual classes for inline composites: [`classify`] handles single @@ -7,7 +8,7 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] -pub enum JapaneseClass { +pub enum JlreqClass { OpeningBracket = 0, ClosingBracket, Hyphen, @@ -40,7 +41,7 @@ pub enum JapaneseClass { TateChuYoko, } -impl JapaneseClass { +impl JlreqClass { pub const COUNT: usize = 30; pub const ALL: [Self; Self::COUNT] = [ @@ -159,11 +160,11 @@ impl JapaneseClass { /// sequences set solid after the first bracket. Returns (shed the trailing /// aki of `before`, shed the leading aki of `after`); a closing mark owns the /// reduction, so both halves never go. -pub fn shed_pair_aki(before: JapaneseClass, after: JapaneseClass) -> (bool, bool) { +pub fn shed_pair_aki(before: JlreqClass, after: JlreqClass) -> (bool, bool) { let preferred = pair_rule(before, after).preferred_em + f32::EPSILON; if before.is_trailing_aki_punctuation() { (0.5 + after.embedded_leading_aki_em() > preferred, false) - } else if after == JapaneseClass::OpeningBracket { + } else if after == JlreqClass::OpeningBracket { (false, before.embedded_trailing_aki_em() + 0.5 > preferred) } else { (false, false) @@ -184,9 +185,9 @@ pub enum AkiShed { /// `palt` or `vpal` in the flow direction) and curly quotes, which are /// full-width in some fonts and proportional in others. A ruby base counts /// as simple ruby (cl-22). -pub fn aki_class(ch: char, ruby_base: bool, proportional: bool) -> Option { +pub fn aki_class(ch: char, ruby_base: bool, proportional: bool) -> Option { if ruby_base { - return Some(JapaneseClass::SimpleRuby); + return Some(JlreqClass::SimpleRuby); } let curly_quote = matches!(ch, '‘' | '’' | '“' | '”'); (!proportional && !curly_quote).then(|| classify(ch)) @@ -196,7 +197,7 @@ pub fn aki_class(ch: char, ruby_base: bool, proportional: bool) -> Option]) -> Vec<(usize, AkiShed)> { +pub fn punctuation_aki_sheds(classes: &[Option]) -> Vec<(usize, AkiShed)> { let mut sheds = Vec::new(); for (index, pair) in classes.windows(2).enumerate() { let [Some(before), Some(after)] = *pair else { @@ -241,29 +242,26 @@ impl PairRule { }; } -const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] { - let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT]; +const fn generated_pair_rules() -> [[PairRule; JlreqClass::COUNT]; JlreqClass::COUNT] { + let mut table = [[PairRule::SOLID; JlreqClass::COUNT]; JlreqClass::COUNT]; let mut before_index = 0; - while before_index < JapaneseClass::COUNT { - let before = JapaneseClass::ALL[before_index]; + while before_index < JlreqClass::COUNT { + let before = JlreqClass::ALL[before_index]; let mut after_index = 0; - while after_index < JapaneseClass::COUNT { - let after = JapaneseClass::ALL[after_index]; + while after_index < JlreqClass::COUNT { + let after = JlreqClass::ALL[after_index]; let mut rule = PairRule::SOLID; rule.suppress_break_with_joiner = before.forbids_line_end() || after.forbids_line_start(); rule.break_allowed = !rule.suppress_break_with_joiner; if before_index == after_index - && matches!( - before, - JapaneseClass::GroupedNumeral | JapaneseClass::Western - ) + && matches!(before, JlreqClass::GroupedNumeral | JlreqClass::Western) { rule.break_allowed = false; } - if matches!(before, JapaneseClass::WesternWordSpace) { + if matches!(before, JlreqClass::WesternWordSpace) { rule.preferred_em = 1.0 / 3.0; rule.minimum_em = 0.25; rule.maximum_em = 0.5; @@ -280,9 +278,9 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl } else if matches!( (before, after), ( - JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop, - JapaneseClass::TateChuYoko - ) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket) + JlreqClass::ClosingBracket | JlreqClass::Comma | JlreqClass::FullStop, + JlreqClass::TateChuYoko + ) | (JlreqClass::TateChuYoko, JlreqClass::OpeningBracket) ) { rule.preferred_em = 0.5; rule.minimum_em = 0.0; @@ -290,22 +288,22 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl rule.shrink_priority = 3; } else if matches!( (before, after), - (JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko) + (JlreqClass::TateChuYoko, JlreqClass::TateChuYoko) ) { // Adjacent cl-30 entries are always separate TCY composites // (characters inside one composite are atomic). rule.maximum_em = 0.25; rule.expand_priority = 3; - } else if matches!(before, JapaneseClass::DividingPunctuation) + } else if matches!(before, JlreqClass::DividingPunctuation) && !matches!( after, - JapaneseClass::ClosingBracket - | JapaneseClass::DividingPunctuation - | JapaneseClass::IdeographicSpace - | JapaneseClass::WesternWordSpace - | JapaneseClass::FullStop - | JapaneseClass::Comma - | JapaneseClass::MiddleDot + JlreqClass::ClosingBracket + | JlreqClass::DividingPunctuation + | JlreqClass::IdeographicSpace + | JlreqClass::WesternWordSpace + | JlreqClass::FullStop + | JlreqClass::Comma + | JlreqClass::MiddleDot ) { // A sentence-ending question/exclamation mark carries one em @@ -316,7 +314,7 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl rule.maximum_em = 1.0; rule.shrink_priority = 2; } else if before.is_trailing_aki_punctuation() - && matches!(after, JapaneseClass::OpeningBracket) + && matches!(after, JlreqClass::OpeningBracket) { // Keep one half-em between the two half-width glyph bodies, // not the sum of both characters' aki. @@ -327,15 +325,15 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl } else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() { // Consecutive closing punctuation sets solid internally; the // last character in the sequence supplies the trailing aki. - } else if matches!(before, JapaneseClass::OpeningBracket) - && matches!(after, JapaneseClass::OpeningBracket) + } else if matches!(before, JlreqClass::OpeningBracket) + && matches!(after, JlreqClass::OpeningBracket) { // Consecutive opening brackets set solid internally; the first // character in the sequence supplies the leading aki. } else if (before.is_trailing_aki_punctuation() - && matches!(after, JapaneseClass::MiddleDot)) - || (matches!(before, JapaneseClass::MiddleDot) - && matches!(after, JapaneseClass::OpeningBracket)) + && matches!(after, JlreqClass::MiddleDot)) + || (matches!(before, JlreqClass::MiddleDot) + && matches!(after, JlreqClass::OpeningBracket)) { rule.preferred_em = 0.25; rule.minimum_em = 0.0; @@ -343,24 +341,24 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl rule.shrink_priority = 3; } else if before.is_trailing_aki_punctuation() { rule.preferred_em = 0.5; - rule.minimum_em = if matches!(before, JapaneseClass::FullStop) { + rule.minimum_em = if matches!(before, JlreqClass::FullStop) { 0.5 } else { 0.0 }; rule.maximum_em = 0.5; - rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) { + rule.shrink_priority = if matches!(before, JlreqClass::FullStop) { 0 } else { 4 }; - } else if matches!(after, JapaneseClass::OpeningBracket) { + } else if matches!(after, JlreqClass::OpeningBracket) { rule.preferred_em = 0.5; rule.minimum_em = 0.0; rule.maximum_em = 0.5; rule.shrink_priority = 4; - } else if matches!(before, JapaneseClass::MiddleDot) - || matches!(after, JapaneseClass::MiddleDot) + } else if matches!(before, JlreqClass::MiddleDot) + || matches!(after, JlreqClass::MiddleDot) { rule.preferred_em = 0.25; rule.minimum_em = 0.0; @@ -382,10 +380,9 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl table } -pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] = - generated_pair_rules(); +pub const PAIR_RULES: [[PairRule; JlreqClass::COUNT]; JlreqClass::COUNT] = generated_pair_rules(); -pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule { +pub const fn pair_rule(before: JlreqClass, after: JlreqClass) -> PairRule { PAIR_RULES[before.index()][after.index()] } @@ -408,64 +405,64 @@ const POSTFIXED_ABBREVIATIONS: &str = concat!("°′″℃¢¢%%‰‱ℓ", " const MATH_SYMBOLS: &str = "==≠≒≃≅≈≡≢<<>>≦≧≤≥≪≫≶≷⋚⋛∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔↔∥∦∽∝⊥⊕⊗"; const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏"; -pub fn classify(c: char) -> JapaneseClass { +pub fn classify(c: char) -> JlreqClass { // Common letters skip the table scans. match c { - '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' => JapaneseClass::Ideographic, - 'a'..='z' | 'A'..='Z' => JapaneseClass::Western, + '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' => JlreqClass::Ideographic, + 'a'..='z' | 'A'..='Z' => JlreqClass::Western, _ => classify_by_tables(c), } } -fn classify_by_tables(c: char) -> JapaneseClass { +fn classify_by_tables(c: char) -> JlreqClass { if OPENING_BRACKETS.contains(c) { - JapaneseClass::OpeningBracket + JlreqClass::OpeningBracket } else if CLOSING_BRACKETS.contains(c) { - JapaneseClass::ClosingBracket + JlreqClass::ClosingBracket } else if HYPHENS.contains(c) { - JapaneseClass::Hyphen + JlreqClass::Hyphen } else if DIVIDING_PUNCTUATION.contains(c) { - JapaneseClass::DividingPunctuation + JlreqClass::DividingPunctuation } else if MIDDLE_DOTS.contains(c) { - JapaneseClass::MiddleDot + JlreqClass::MiddleDot } else if FULL_STOPS.contains(c) { - JapaneseClass::FullStop + JlreqClass::FullStop } else if COMMAS.contains(c) { - JapaneseClass::Comma + JlreqClass::Comma } else if INSEPARABLE.contains(c) { - JapaneseClass::Inseparable + JlreqClass::Inseparable } else if ITERATION_MARKS.contains(c) { - JapaneseClass::IterationMark + JlreqClass::IterationMark } else if c == 'ー' || c == 'ー' { - JapaneseClass::ProlongedSoundMark + JlreqClass::ProlongedSoundMark } else if SMALL_KANA.contains(c) { - JapaneseClass::SmallKana + JlreqClass::SmallKana } else if PREFIXED_ABBREVIATIONS.contains(c) { - JapaneseClass::PrefixedAbbreviation + JlreqClass::PrefixedAbbreviation } else if POSTFIXED_ABBREVIATIONS.contains(c) { - JapaneseClass::PostfixedAbbreviation + JlreqClass::PostfixedAbbreviation } else if c == '\u{3000}' { - JapaneseClass::IdeographicSpace + JlreqClass::IdeographicSpace } else if MATH_SYMBOLS.contains(c) { - JapaneseClass::MathSymbol + JlreqClass::MathSymbol } else if MATH_OPERATORS.contains(c) { - JapaneseClass::MathOperator + JlreqClass::MathOperator } else if c == ' ' || c == '\t' || c == '\u{00a0}' { - JapaneseClass::WesternWordSpace + JlreqClass::WesternWordSpace } else if c.is_ascii_digit() { - JapaneseClass::GroupedNumeral + JlreqClass::GroupedNumeral } else if is_hiragana(c) { - JapaneseClass::Hiragana + JlreqClass::Hiragana } else if is_katakana(c) { - JapaneseClass::Katakana + JlreqClass::Katakana } else if is_ideographic(c) { - JapaneseClass::Ideographic + JlreqClass::Ideographic } else if is_squared_unit(c) { - JapaneseClass::PostfixedAbbreviation + JlreqClass::PostfixedAbbreviation } else if is_ideographic_symbol(c) { - JapaneseClass::Ideographic + JlreqClass::Ideographic } else { - JapaneseClass::Western + JlreqClass::Western } } @@ -485,15 +482,25 @@ pub fn keeps_together(before: char, after: char) -> bool { /// `keeps_together` for characters already paired with their classes. pub fn keeps_together_classified( - (before, before_class): (char, JapaneseClass), - (after, after_class): (char, JapaneseClass), + (before, before_class): (char, JlreqClass), + (after, after_class): (char, JlreqClass), ) -> bool { - (before == after && before_class == JapaneseClass::Inseparable) + (before == after && before_class == JlreqClass::Inseparable) || (is_numeral(before) && is_numeral(after) && !(before.is_ascii_digit() && after.is_ascii_digit())) - || (is_numeral(before) && after_class == JapaneseClass::PostfixedAbbreviation) - || (before_class == JapaneseClass::PrefixedAbbreviation && is_numeral(after)) + || (is_numeral(before) && after_class == JlreqClass::PostfixedAbbreviation) + || (before_class == JlreqClass::PrefixedAbbreviation && is_numeral(after)) +} + +/// Kinsoku shori: a character that may not start a line. +pub fn forbidden_at_line_start(c: char) -> bool { + classify(c).forbids_line_start() +} + +/// Kinsoku shori: a character that may not end a line. +pub fn forbidden_at_line_end(c: char) -> bool { + classify(c).forbids_line_end() } /// Whether a line may break between two adjacent characters: the pair table @@ -502,9 +509,10 @@ pub fn break_allowed_between(before: char, after: char) -> bool { pair_rule(classify(before), classify(after)).break_allowed && !keeps_together(before, after) } -/// True for characters that only appear in Japanese text: kana, kanji, and -/// the CJK punctuation and full-width form blocks. -pub fn is_japanese_text_char(c: char) -> bool { +/// True for CJK text characters: kana, Han ideographs, and the CJK symbols +/// and punctuation, katakana extension and half/full-width form blocks. +/// Hangul is not included. +pub fn is_cjk_text_char(c: char) -> bool { classify(c).is_japanese_letter() || matches!(u32::from(c), 0x3000..=0x30FF | 0x31F0..=0x31FF | 0xFF00..=0xFFEF) } @@ -560,23 +568,40 @@ fn is_ideographic_symbol(c: char) -> bool { mod tests { use super::*; + #[test] + fn classes_forbidden_at_start() { + for c in "、。」』)ーっゃァッ々・!?".chars() { + assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}"); + } + for c in "あ漢A1「( ".chars() { + assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}"); + } + } + + #[test] + fn classes_forbidden_at_end() { + for c in "「『([【〈《".chars() { + assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}"); + } + for c in "あ漢A1」)。".chars() { + assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}"); + } + } + #[test] fn aki_class_leaves_out_curly_quotes_and_proportional_punctuation() { assert_eq!(aki_class('“', false, false), None); assert_eq!(aki_class('」', false, true), None); assert_eq!( aki_class('」', false, false), - Some(JapaneseClass::ClosingBracket) - ); - assert_eq!( - aki_class('」', true, false), - Some(JapaneseClass::SimpleRuby) + Some(JlreqClass::ClosingBracket) ); + assert_eq!(aki_class('」', true, false), Some(JlreqClass::SimpleRuby)); } #[test] fn punctuation_aki_sheds_the_trailing_aki_of_a_closing_mark() { - use JapaneseClass::*; + use JlreqClass::*; let classes = [Some(ClosingBracket), Some(OpeningBracket)]; assert_eq!( punctuation_aki_sheds(&classes), @@ -586,22 +611,22 @@ mod tests { #[test] fn punctuation_aki_sheds_the_leading_aki_of_a_second_opening_bracket() { - use JapaneseClass::*; + use JlreqClass::*; let classes = [Some(OpeningBracket), Some(OpeningBracket)]; assert_eq!(punctuation_aki_sheds(&classes), vec![(1, AkiShed::Leading)]); } #[test] fn punctuation_aki_sheds_nothing_next_to_an_unknown_aki() { - use JapaneseClass::*; + use JlreqClass::*; let classes = [Some(ClosingBracket), None, Some(OpeningBracket)]; assert!(punctuation_aki_sheds(&classes).is_empty()); } #[test] fn class_model_contains_all_thirty_jlreq_classes_in_order() { - assert_eq!(JapaneseClass::ALL.len(), 30); - for (index, class) in JapaneseClass::ALL.iter().enumerate() { + assert_eq!(JlreqClass::ALL.len(), 30); + for (index, class) in JlreqClass::ALL.iter().enumerate() { assert_eq!(class.index(), index); } } @@ -609,29 +634,29 @@ mod tests { #[test] fn classifies_representative_jlreq_characters() { let cases = [ - ('「', JapaneseClass::OpeningBracket), - ('」', JapaneseClass::ClosingBracket), - ('〜', JapaneseClass::Hyphen), - ('!', JapaneseClass::DividingPunctuation), - ('・', JapaneseClass::MiddleDot), - ('。', JapaneseClass::FullStop), - ('、', JapaneseClass::Comma), - ('…', JapaneseClass::Inseparable), - ('々', JapaneseClass::IterationMark), - ('ー', JapaneseClass::ProlongedSoundMark), - ('ょ', JapaneseClass::SmallKana), - ('¥', JapaneseClass::PrefixedAbbreviation), - ('%', JapaneseClass::PostfixedAbbreviation), - ('\u{3000}', JapaneseClass::IdeographicSpace), - ('あ', JapaneseClass::Hiragana), - ('ア', JapaneseClass::Katakana), - ('≠', JapaneseClass::MathSymbol), - ('+', JapaneseClass::MathOperator), - ('漢', JapaneseClass::Ideographic), - ('2', JapaneseClass::GroupedNumeral), - ('㎏', JapaneseClass::PostfixedAbbreviation), - (' ', JapaneseClass::WesternWordSpace), - ('A', JapaneseClass::Western), + ('「', JlreqClass::OpeningBracket), + ('」', JlreqClass::ClosingBracket), + ('〜', JlreqClass::Hyphen), + ('!', JlreqClass::DividingPunctuation), + ('・', JlreqClass::MiddleDot), + ('。', JlreqClass::FullStop), + ('、', JlreqClass::Comma), + ('…', JlreqClass::Inseparable), + ('々', JlreqClass::IterationMark), + ('ー', JlreqClass::ProlongedSoundMark), + ('ょ', JlreqClass::SmallKana), + ('¥', JlreqClass::PrefixedAbbreviation), + ('%', JlreqClass::PostfixedAbbreviation), + ('\u{3000}', JlreqClass::IdeographicSpace), + ('あ', JlreqClass::Hiragana), + ('ア', JlreqClass::Katakana), + ('≠', JlreqClass::MathSymbol), + ('+', JlreqClass::MathOperator), + ('漢', JlreqClass::Ideographic), + ('2', JlreqClass::GroupedNumeral), + ('㎏', JlreqClass::PostfixedAbbreviation), + (' ', JlreqClass::WesternWordSpace), + ('A', JlreqClass::Western), ]; for (character, expected) in cases { assert_eq!(classify(character), expected, "wrong class for {character}"); @@ -640,56 +665,48 @@ mod tests { #[test] fn generated_rules_cover_every_class_pair() { - assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT); - assert!(PAIR_RULES - .iter() - .all(|row| row.len() == JapaneseClass::COUNT)); + assert_eq!(PAIR_RULES.len(), JlreqClass::COUNT); + assert!(PAIR_RULES.iter().all(|row| row.len() == JlreqClass::COUNT)); } #[test] fn generated_rules_encode_kinsoku_and_atomic_runs() { - assert!( - !pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed - ); - assert!( - !pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed - ); - assert!( - !pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed - ); - assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed); + assert!(!pair_rule(JlreqClass::OpeningBracket, JlreqClass::Ideographic).break_allowed); + assert!(!pair_rule(JlreqClass::Ideographic, JlreqClass::ClosingBracket).break_allowed); + assert!(!pair_rule(JlreqClass::GroupedNumeral, JlreqClass::GroupedNumeral).break_allowed); + assert!(pair_rule(JlreqClass::Ideographic, JlreqClass::Ideographic).break_allowed); } #[test] fn generated_rules_encode_script_and_tcy_spacing() { - let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western); + let script = pair_rule(JlreqClass::Ideographic, JlreqClass::Western); assert_eq!(script.preferred_em, 0.25); assert_eq!(script.minimum_em, 0.125); assert_eq!(script.maximum_em, 0.5); - let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko); + let tcy = pair_rule(JlreqClass::Comma, JlreqClass::TateChuYoko); assert_eq!(tcy.preferred_em, 0.5); assert_eq!( - pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em, + pair_rule(JlreqClass::TateChuYoko, JlreqClass::Comma).preferred_em, 0.0 ); assert_eq!( - pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em, + pair_rule(JlreqClass::OpeningBracket, JlreqClass::TateChuYoko).preferred_em, 0.0 ); assert_eq!( - pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em, + pair_rule(JlreqClass::TateChuYoko, JlreqClass::OpeningBracket).preferred_em, 0.5 ); assert_eq!( - pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em, + pair_rule(JlreqClass::Ideographic, JlreqClass::TateChuYoko).preferred_em, 0.0 ); assert_eq!( - pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em, + pair_rule(JlreqClass::TateChuYoko, JlreqClass::Ideographic).preferred_em, 0.0 ); - let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko); + let adjacent_tcy = pair_rule(JlreqClass::TateChuYoko, JlreqClass::TateChuYoko); assert_eq!(adjacent_tcy.preferred_em, 0.0); assert_eq!(adjacent_tcy.maximum_em, 0.25); assert_eq!(adjacent_tcy.expand_priority, 3); @@ -698,58 +715,54 @@ mod tests { #[test] fn generated_rules_encode_punctuation_sequences() { assert_eq!( - pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em, + pair_rule(JlreqClass::Ideographic, JlreqClass::OpeningBracket).preferred_em, 0.5 ); assert_eq!( - pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em, + pair_rule(JlreqClass::ClosingBracket, JlreqClass::Ideographic).preferred_em, 0.5 ); assert_eq!( - pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em, + pair_rule(JlreqClass::FullStop, JlreqClass::ClosingBracket).preferred_em, 0.0 ); assert_eq!( - pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em, + pair_rule(JlreqClass::ClosingBracket, JlreqClass::OpeningBracket).preferred_em, 0.5 ); assert_eq!( - pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em, + pair_rule(JlreqClass::ClosingBracket, JlreqClass::MiddleDot).preferred_em, 0.25 ); assert_eq!( - pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em, + pair_rule(JlreqClass::MiddleDot, JlreqClass::OpeningBracket).preferred_em, 0.25 ); assert_eq!( - pair_rule( - JapaneseClass::DividingPunctuation, - JapaneseClass::Ideographic - ) - .preferred_em, + pair_rule(JlreqClass::DividingPunctuation, JlreqClass::Ideographic).preferred_em, 1.0 ); assert_eq!( pair_rule( - JapaneseClass::DividingPunctuation, - JapaneseClass::DividingPunctuation + JlreqClass::DividingPunctuation, + JlreqClass::DividingPunctuation ) .preferred_em, 0.0 ); assert_eq!( pair_rule( - JapaneseClass::DividingPunctuation, - JapaneseClass::IdeographicSpace + JlreqClass::DividingPunctuation, + JlreqClass::IdeographicSpace ) .preferred_em, 0.0 ); - let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana); + let solid_japanese = pair_rule(JlreqClass::Ideographic, JlreqClass::Hiragana); assert_eq!(solid_japanese.preferred_em, 0.0); assert_eq!(solid_japanese.maximum_em, 0.25); assert_eq!(solid_japanese.expand_priority, 3); - let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby); + let ruby_adjacency = pair_rule(JlreqClass::SimpleRuby, JlreqClass::JukugoRuby); assert_eq!(ruby_adjacency.preferred_em, 0.0); assert_eq!(ruby_adjacency.maximum_em, 0.0); assert_eq!(ruby_adjacency.expand_priority, 0); @@ -758,49 +771,49 @@ mod tests { #[test] fn classify_follows_jlreq_appendix_a() { let cases = [ - ('—', JapaneseClass::Inseparable), - ('〝', JapaneseClass::OpeningBracket), - ('〟', JapaneseClass::ClosingBracket), - ('〞', JapaneseClass::ClosingBracket), - ('«', JapaneseClass::OpeningBracket), - ('»', JapaneseClass::ClosingBracket), - ('⦅', JapaneseClass::OpeningBracket), - ('⦆', JapaneseClass::ClosingBracket), - ('⦅', JapaneseClass::OpeningBracket), - ('⦆', JapaneseClass::ClosingBracket), - ('A', JapaneseClass::Ideographic), - ('z', JapaneseClass::Ideographic), - ('1', JapaneseClass::Ideographic), - ('〇', JapaneseClass::Ideographic), - ('〒', JapaneseClass::Ideographic), - ('※', JapaneseClass::Ideographic), - ('①', JapaneseClass::Ideographic), - ('㈱', JapaneseClass::Ideographic), - ('○', JapaneseClass::Ideographic), - ('★', JapaneseClass::Ideographic), - ('→', JapaneseClass::Ideographic), - ('Ⅳ', JapaneseClass::Ideographic), - ('㏍', JapaneseClass::Ideographic), - ('≒', JapaneseClass::MathSymbol), - ('≡', JapaneseClass::MathSymbol), - ('≪', JapaneseClass::MathSymbol), - ('≈', JapaneseClass::MathSymbol), - ('∝', JapaneseClass::MathSymbol), - ('↔', JapaneseClass::MathSymbol), - ('€', JapaneseClass::PrefixedAbbreviation), - ('㎡', JapaneseClass::PostfixedAbbreviation), - ('㏄', JapaneseClass::PostfixedAbbreviation), - ('㌔', JapaneseClass::PostfixedAbbreviation), - ('ℓ', JapaneseClass::PostfixedAbbreviation), - ('。', JapaneseClass::FullStop), - ('、', JapaneseClass::Comma), - ('「', JapaneseClass::OpeningBracket), - ('」', JapaneseClass::ClosingBracket), - ('ァ', JapaneseClass::SmallKana), - ('ッ', JapaneseClass::SmallKana), - ('ー', JapaneseClass::ProlongedSoundMark), - ('\u{30000}', JapaneseClass::Ideographic), - ('\u{31350}', JapaneseClass::Ideographic), + ('—', JlreqClass::Inseparable), + ('〝', JlreqClass::OpeningBracket), + ('〟', JlreqClass::ClosingBracket), + ('〞', JlreqClass::ClosingBracket), + ('«', JlreqClass::OpeningBracket), + ('»', JlreqClass::ClosingBracket), + ('⦅', JlreqClass::OpeningBracket), + ('⦆', JlreqClass::ClosingBracket), + ('⦅', JlreqClass::OpeningBracket), + ('⦆', JlreqClass::ClosingBracket), + ('A', JlreqClass::Ideographic), + ('z', JlreqClass::Ideographic), + ('1', JlreqClass::Ideographic), + ('〇', JlreqClass::Ideographic), + ('〒', JlreqClass::Ideographic), + ('※', JlreqClass::Ideographic), + ('①', JlreqClass::Ideographic), + ('㈱', JlreqClass::Ideographic), + ('○', JlreqClass::Ideographic), + ('★', JlreqClass::Ideographic), + ('→', JlreqClass::Ideographic), + ('Ⅳ', JlreqClass::Ideographic), + ('㏍', JlreqClass::Ideographic), + ('≒', JlreqClass::MathSymbol), + ('≡', JlreqClass::MathSymbol), + ('≪', JlreqClass::MathSymbol), + ('≈', JlreqClass::MathSymbol), + ('∝', JlreqClass::MathSymbol), + ('↔', JlreqClass::MathSymbol), + ('€', JlreqClass::PrefixedAbbreviation), + ('㎡', JlreqClass::PostfixedAbbreviation), + ('㏄', JlreqClass::PostfixedAbbreviation), + ('㌔', JlreqClass::PostfixedAbbreviation), + ('ℓ', JlreqClass::PostfixedAbbreviation), + ('。', JlreqClass::FullStop), + ('、', JlreqClass::Comma), + ('「', JlreqClass::OpeningBracket), + ('」', JlreqClass::ClosingBracket), + ('ァ', JlreqClass::SmallKana), + ('ッ', JlreqClass::SmallKana), + ('ー', JlreqClass::ProlongedSoundMark), + ('\u{30000}', JlreqClass::Ideographic), + ('\u{31350}', JlreqClass::Ideographic), ]; for (character, expected) in cases { assert_eq!(classify(character), expected, "wrong class for {character}"); @@ -821,13 +834,13 @@ mod tests { #[test] fn ascii_and_latin_symbols_stay_western() { for character in ['(', ')', '[', ']', 'A', '©', '†', 'α'] { - assert_eq!(classify(character), JapaneseClass::Western, "{character}"); + assert_eq!(classify(character), JlreqClass::Western, "{character}"); } } #[test] fn only_identical_inseparable_pairs_are_unbreakable() { - assert!(!JapaneseClass::Inseparable.forbids_line_start()); + assert!(!JlreqClass::Inseparable.forbids_line_start()); assert!(break_allowed_between('あ', '…')); assert!(!break_allowed_between('…', '…')); assert!(!break_allowed_between('—', '—')); @@ -836,8 +849,8 @@ mod tests { #[test] fn abbreviations_bind_only_to_numerals() { - assert!(!JapaneseClass::PostfixedAbbreviation.forbids_line_start()); - assert!(!JapaneseClass::PrefixedAbbreviation.forbids_line_end()); + assert!(!JlreqClass::PostfixedAbbreviation.forbids_line_start()); + assert!(!JlreqClass::PrefixedAbbreviation.forbids_line_end()); assert!(!break_allowed_between('0', '㎏')); assert!(!break_allowed_between('0', '%')); assert!(!break_allowed_between('$', '5')); diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/line_breaks.rs similarity index 93% rename from render-wasm/src/shapes/kinsoku.rs rename to render-wasm/src/shapes/line_breaks.rs index 6dc34d74eb..e12e886c86 100644 --- a/render-wasm/src/shapes/kinsoku.rs +++ b/render-wasm/src/shapes/line_breaks.rs @@ -1,11 +1,14 @@ -//! Japanese line-breaking rules (kinsoku shori). +//! Layout-text rewrite for horizontal CJK line breaking. //! -//! Skia's default break iterator allows closing punctuation, the prolonged -//! sound mark or small kana at a line start, and opening brackets at a line -//! end. skia-safe exposes no ICU BreakIterator, so this module suppresses -//! those breaks by inserting U+2060 WORD JOINER into the text handed to -//! skparagraph. UAX #14 also forbids a break before … and ‥, which JLREQ -//! allows; U+200B ZERO WIDTH SPACE reopens it. The same transform inserts the JLREQ quarter-em space at +//! skia-safe exposes no ICU BreakIterator, so the layout policy steers +//! skparagraph's breaker by editing the text handed to it. U+2060 WORD +//! JOINER suppresses a break the policy forbids (Japanese kinsoku shori, +//! Chinese and Korean punctuation, nonbreaking units and annotations). +//! U+200B ZERO WIDTH SPACE reopens a break that UAX #14 forbids but the +//! policy allows, such as before … and ‥ in JLREQ, or an emergency break +//! inside an overlong unit. U+2028 LINE SEPARATOR keeps the original wrap +//! budget around an indivisible unit that overflows its box. In Japanese +//! text the same transform inserts the JLREQ quarter-em space at //! Japanese/Western boundaries and sets Western word spaces to one third em. //! //! Inserted characters shift every UTF-16 offset the laid-out paragraph @@ -35,18 +38,10 @@ pub const WESTERN_WORD_SPACE: char = '\u{2004}'; /// question or exclamation mark. pub const IDEOGRAPHIC_SPACE: char = '\u{3000}'; -use super::japanese::{classify, keeps_together_classified, pair_rule, JapaneseClass}; +use super::jlreq::{classify, keeps_together_classified, pair_rule, JlreqClass}; use super::text_policy::TextLayoutPolicy; use unicode_segmentation::UnicodeSegmentation; -pub fn forbidden_at_line_start(c: char) -> bool { - classify(c).forbids_line_start() -} - -pub fn forbidden_at_line_end(c: char) -> bool { - classify(c).forbids_line_end() -} - /// Translates between original UTF-16 offsets (the source-of-truth span text) /// and layout-text UTF-16 offsets (the text handed to skparagraph). #[derive(Debug, Clone, Default, PartialEq)] @@ -92,7 +87,8 @@ impl OffsetMap { } } -/// Applies the horizontal Japanese layout-text transform: inserts WORD +/// Applies the horizontal Japanese layout-text transform (the `Japanese` +/// policy of `apply_policy_with_breaks`): inserts WORD /// JOINER wherever a break would violate kinsoku, inserts a quarter-em space /// between Japanese letters and Western letters or digits, and sets /// breakable ASCII spaces to one third em. Span boundaries are transparent; @@ -130,7 +126,7 @@ pub fn apply_policy_with_breaks( let mut inserted: Vec = Vec::new(); let mut out: Vec = Vec::with_capacity(span_texts.len()); // Base character of the previous grapheme cluster, with its class. - let mut prev: Option<(char, JapaneseClass)> = None; + let mut prev: Option<(char, JlreqClass)> = None; let mut changed = false; // Running position in shifted UTF-16 coordinates. let mut shifted_pos: usize = 0; @@ -190,7 +186,7 @@ pub fn apply_policy_with_breaks( WORD_JOINER }); } else if policy == TextLayoutPolicy::Japanese - && current.1 == JapaneseClass::Inseparable + && current.1 == JlreqClass::Inseparable && prev.is_some_and(|p| pair_rule(p.1, current.1).break_allowed) { insert(BREAK_OPPORTUNITY); @@ -218,8 +214,8 @@ pub fn apply_policy_with_breaks( /// JLREQ §3.2.6 boundary between Japanese text and a Western letter or /// digit. Western symbols, brackets and emoji set solid. fn is_japanese_western_boundary( - (before, before_class): (char, JapaneseClass), - (after, after_class): (char, JapaneseClass), + (before, before_class): (char, JlreqClass), + (after, after_class): (char, JlreqClass), ) -> bool { (before_class.is_japanese_text() && after_class.is_western_run() && after.is_alphanumeric()) || (before_class.is_western_run() @@ -230,16 +226,17 @@ fn is_japanese_western_boundary( /// A question or exclamation mark that ends a sentence takes a one-em space /// (JLREQ §3.1.6), as in vertical layout. fn takes_em_space_after( - (_, before_class): (char, JapaneseClass), - (_, after_class): (char, JapaneseClass), + (_, before_class): (char, JlreqClass), + (_, after_class): (char, JlreqClass), ) -> bool { - before_class == JapaneseClass::DividingPunctuation + before_class == JlreqClass::DividingPunctuation && pair_rule(before_class, after_class).preferred_em >= 1.0 } #[cfg(test)] mod tests { use super::*; + use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start}; use skia_safe::textlayout::{ FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider, }; @@ -256,30 +253,6 @@ mod tests { .expect("expected kinsoku insertions") } - // ----------------------------------------------------------------- - // Character classes - // ----------------------------------------------------------------- - - #[test] - fn classes_forbidden_at_start() { - for c in "、。」』)ーっゃァッ々・!?".chars() { - assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}"); - } - for c in "あ漢A1「( ".chars() { - assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}"); - } - } - - #[test] - fn classes_forbidden_at_end() { - for c in "「『([【〈《".chars() { - assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}"); - } - for c in "あ漢A1」)。".chars() { - assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}"); - } - } - // ----------------------------------------------------------------- // Insertion // ----------------------------------------------------------------- diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index ec211a6382..4c86b01f64 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -27,13 +27,13 @@ use std::collections::HashSet; use std::rc::Rc; use unicode_segmentation::UnicodeSegmentation; +use super::text_attrs::*; use super::text_horizontal::*; -use super::text_japanese::*; use super::{FontFamily, FontStyle}; use crate::math::Point; use crate::render::FallbackFonts; #[cfg(test)] -use crate::shapes::kinsoku; +use crate::shapes::line_breaks; use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; @@ -765,12 +765,12 @@ impl TextContent { (sheds, laid_out) }; let spacing = if paragraph.text_align() == TextAlign::Distribute { - horizontal_policy_justify_spacing(paragraph, &laid_out, plan, width) + 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 { - horizontal_justify_spacing(&laid_out, plan, width) + horizontal_jlreq_justify_spacing(&laid_out, plan, width) } else { - horizontal_policy_justify_spacing(paragraph, &laid_out, plan, width) + horizontal_pairwise_justify_spacing(paragraph, &laid_out, plan, width) } } else { vec![Vec::new(); plan.texts.len()] @@ -798,7 +798,7 @@ impl TextContent { .children() .iter() .zip(&plan.texts) - .zip(&plan.sheds) + .zip(&plan.pair_spacing) .enumerate() { let combined: Vec<(usize, f32)> = sheds @@ -1489,7 +1489,7 @@ impl TextContent { .children() .iter() .zip(&plan.texts) - .zip(&plan.sheds) + .zip(&plan.pair_spacing) .zip(&plan.ruby_spacing.adjustments) .enumerate() { @@ -2379,7 +2379,7 @@ impl Paragraph { /// of laid-out offsets must translate through the map. Only paragraphs /// with an explicit typography profile are transformed. #[cfg(test)] - pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) { + pub fn layout_span_texts(&self) -> (Vec, line_breaks::OffsetMap) { layout_span_texts(self) } @@ -2539,7 +2539,7 @@ pub struct TextSpan { pub ruby_size: RubySize, pub ruby_align: RubyAlign, pub ruby_overhang: RubyOverhang, - pub ruby_side: RubySide, + pub ruby_side: AnnotationSide, /// Warichu (割注): two half-size lines stacked in one column position. pub warichu: bool, pub font_features: FontFeatures, @@ -2573,7 +2573,7 @@ impl Default for TextSpan { ruby_size: RubySize::default(), ruby_align: RubyAlign::default(), ruby_overhang: RubyOverhang::default(), - ruby_side: RubySide::default(), + ruby_side: AnnotationSide::default(), warichu: false, font_features: FontFeatures::default(), annotation_clearance: AnnotationClearance::default(), @@ -2657,9 +2657,9 @@ impl TextSpan { super::text_zhuyin::syllable(self).is_some() } - pub fn effective_ruby_side(&self) -> RubySide { + pub fn effective_ruby_side(&self) -> AnnotationSide { if self.is_zhuyin() { - RubySide::Over + AnnotationSide::Over } else { self.ruby_side } @@ -2682,11 +2682,11 @@ impl TextSpan { /// the line height: the ruby at its size plus the emphasis marks. `None` /// keeps the set line height, so annotations sit in the line gap. pub fn annotation_room_em(&self) -> f32 { - self.annotation_side_room_em(RubySide::Over, false) - + self.annotation_side_room_em(RubySide::Under, false) + self.annotation_side_room_em(AnnotationSide::Over, false) + + self.annotation_side_room_em(AnnotationSide::Under, false) } - pub fn emphasis_side(&self, vertical: bool) -> RubySide { + 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(), @@ -2707,14 +2707,14 @@ impl TextSpan { && self.effective_ruby_side() == self.emphasis_side(vertical) } - pub fn decoration_room_em(&self, side: RubySide, vertical: bool) -> f32 { + pub fn decoration_room_em(&self, side: AnnotationSide, vertical: bool) -> f32 { let underline = self .text_decoration .is_some_and(|kind| kind.contains(TextDecoration::UNDERLINE)); let underline_side = if vertical { self.text_underline_position.side() } else { - RubySide::Under + AnnotationSide::Under }; let positioned = self.text_underline_position != TextUnderlinePosition::Auto || self.text_emphasis_position != TextEmphasisPosition::Auto @@ -2741,7 +2741,7 @@ impl TextSpan { ruby + self.font_size * self.decoration_room_em(self.emphasis_side(vertical), vertical) } - pub fn annotation_side_room_em(&self, side: RubySide, vertical: bool) -> f32 { + pub fn annotation_side_room_em(&self, side: AnnotationSide, vertical: bool) -> f32 { if !self.annotation_clearance.is_auto() { return 0.0; } @@ -2754,7 +2754,7 @@ impl TextSpan { 0.0 }; let emphasis = if !self.text_emphasis.is_none() && self.emphasis_side(vertical) == side { - super::text_japanese::EMPHASIS_FONT_SCALE + super::text_attrs::EMPHASIS_FONT_SCALE } else { 0.0 }; diff --git a/render-wasm/src/shapes/text_allocation.rs b/render-wasm/src/shapes/text_allocation.rs new file mode 100644 index 0000000000..c859de7ebf --- /dev/null +++ b/render-wasm/src/shapes/text_allocation.rs @@ -0,0 +1,40 @@ +//! Equal division of an adjustment over capped opportunities, shared by the +//! horizontal and vertical justification and squeezing passes. + +/// Amounts below this count as zero when dividing adjustments. +const ALLOCATION_EPSILON: f32 = 0.0001; + +/// Divide `amount` equally across capped opportunities, redistributing the +/// remainder whenever one opportunity reaches its cap. +pub(crate) fn capped_equal_allocations( + capacities: &[(usize, f32)], + amount: f32, +) -> Vec<(usize, f32)> { + let mut allocations: Vec<(usize, f32)> = capacities.iter().map(|(i, _)| (*i, 0.0)).collect(); + let mut remaining = amount.max(0.0); + while remaining > ALLOCATION_EPSILON { + let active: Vec = capacities + .iter() + .enumerate() + .filter_map(|(slot, (_, cap))| { + (allocations[slot].1 + ALLOCATION_EPSILON < *cap).then_some(slot) + }) + .collect(); + if active.is_empty() { + break; + } + let share = remaining / active.len() as f32; + let mut used = 0.0; + for slot in active { + let cap = capacities[slot].1; + let delta = share.min(cap - allocations[slot].1); + allocations[slot].1 += delta; + used += delta; + } + if used <= ALLOCATION_EPSILON { + break; + } + remaining -= used; + } + allocations +} diff --git a/render-wasm/src/shapes/text_attrs.rs b/render-wasm/src/shapes/text_attrs.rs new file mode 100644 index 0000000000..25fff66752 --- /dev/null +++ b/render-wasm/src/shapes/text_attrs.rs @@ -0,0 +1,251 @@ +//! Typographic span and paragraph attributes shared by the horizontal and +//! vertical engines: writing mode, orientation, emphasis, decoration +//! positions, annotation clearance, font features, line adjustment and the +//! ruby presentation enums. + +use crate::shapes::jlreq::classify; + +pub const EMPHASIS_FONT_SCALE: f32 = 0.5; + +/// Whether an emphasis mark goes on `character`: never on whitespace nor on +/// the punctuation JLREQ leaves unmarked. +pub(crate) fn emphasis_char_allowed(character: char) -> bool { + !character.is_whitespace() && !classify(character).is_emphasis_prohibited() +} + +/// Block flow direction of a paragraph. Horizontal uses skparagraph; +/// vertical-rl uses the custom vertical pass: columns top to bottom, +/// advancing right to left. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum WritingMode { + #[default] + HorizontalTb, + VerticalRl, +} + +/// How a line slightly too long for its box is fitted (JLREQ §3.8.1; Adobe's +/// Kinsoku Type). Squeezing reduces punctuation aki to keep the next +/// character; pushing out moves characters to the next line. Alignment then +/// decides what happens to the space a line leaves over. +// The variants are Adobe's Kinsoku Type values and the JS keys. +#[allow(clippy::enum_variant_names)] +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum LineAdjustment { + /// Squeeze first; push out when the aki cannot absorb the overflow. + #[default] + PushInFirst, + /// Push out first; squeeze only when kinsoku leaves no other break. + PushOutFirst, + /// Never squeeze. + PushOutOnly, +} + +/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs +/// sideways, `Upright` keeps every character upright. Ignored in +/// horizontal writing. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextOrientation { + #[default] + Mixed, + Upright, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextCombineUpright { + #[default] + None, + All, + /// Runs of up to 4 ASCII or full-width digits combine into one upright + /// cell, so a lone digit stands upright (CSS `digits`). + Digits, + /// Like `Digits`, for runs of up to 2 (CSS `digits 2`). + Digits2, + /// Like `Digits`, for runs of up to 3. + Digits3, +} + +impl TextCombineUpright { + /// Longest digit run that combines, when digits mode is active. + pub fn digits_max(self) -> Option { + match self { + TextCombineUpright::Digits => Some(4), + TextCombineUpright::Digits2 => Some(2), + TextCombineUpright::Digits3 => Some(3), + _ => None, + } + } +} + +/// Independent emphasis positions in the two writing directions. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextEmphasisPosition { + #[default] + Auto, + OverRight, + OverLeft, + UnderRight, + UnderLeft, +} + +impl TextEmphasisPosition { + pub fn side(self, vertical: bool, chinese: bool) -> AnnotationSide { + let under = if vertical { + matches!(self, Self::OverLeft | Self::UnderLeft) + } else { + matches!(self, Self::UnderRight | Self::UnderLeft) || (self == Self::Auto && chinese) + }; + if under { + AnnotationSide::Under + } else { + AnnotationSide::Over + } + } +} + +/// CSS underline position: horizontal depth and vertical side are independent. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextUnderlinePosition { + #[default] + Auto, + Under, + Left, + Right, + UnderLeft, + UnderRight, +} + +impl TextUnderlinePosition { + pub fn side(self) -> AnnotationSide { + if matches!(self, Self::Right | Self::UnderRight) { + AnnotationSide::Over + } else { + AnnotationSide::Under + } + } + + pub fn below_ink(self, chinese: bool) -> bool { + matches!(self, Self::Under | Self::UnderLeft | Self::UnderRight) + || (self == Self::Auto && chinese) + } +} + +/// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS +/// `text-emphasis-style`. Position is a separate style with a language default. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextEmphasis { + #[default] + None, + FilledDot, + OpenDot, + FilledCircle, + OpenCircle, + FilledSesame, + OpenSesame, +} + +impl TextEmphasis { + pub fn is_none(self) -> bool { + matches!(self, TextEmphasis::None) + } + + /// The glyph drawn as the emphasis mark, following the CSS + /// `text-emphasis-style` character mapping. + pub fn mark_char(self) -> Option { + match self { + TextEmphasis::None => None, + TextEmphasis::FilledDot => Some('•'), + TextEmphasis::OpenDot => Some('◦'), + TextEmphasis::FilledCircle => Some('●'), + TextEmphasis::OpenCircle => Some('○'), + TextEmphasis::FilledSesame => Some('﹅'), + TextEmphasis::OpenSesame => Some('﹆'), + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum FontFeatures { + #[default] + None, + Palt, + Vpal, +} + +/// Whether ruby and emphasis on the same side stack. `Auto` places emphasis +/// outside over-side ruby and reserves room for both; `None` lets them share +/// one layer. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum AnnotationClearance { + #[default] + None, + Auto, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubySize { + #[default] + Half, + Third, + Quarter, +} + +impl RubySize { + pub fn scale(self) -> f32 { + match self { + Self::Half => 0.5, + Self::Third => 1.0 / 3.0, + Self::Quarter => 0.25, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubyAlign { + #[default] + SpaceAround, + Center, + Start, + SpaceBetween, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubyOverhang { + #[default] + Auto, + None, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum AnnotationSide { + #[default] + Over, + Under, +} + +impl AnnotationClearance { + pub fn is_auto(self) -> bool { + matches!(self, AnnotationClearance::Auto) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emphasis_excludes_whitespace_and_japanese_punctuation() { + for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars() + { + assert!( + !emphasis_char_allowed(character), + "emphasis must skip {character:?}" + ); + } + for character in "漢あA1・!?".chars() { + assert!( + emphasis_char_allowed(character), + "emphasis should mark {character:?}" + ); + } + } +} diff --git a/render-wasm/src/shapes/text_horizontal/emphasis.rs b/render-wasm/src/shapes/text_horizontal/emphasis.rs index 08cfea70a4..7da947e3aa 100644 --- a/render-wasm/src/shapes/text_horizontal/emphasis.rs +++ b/render-wasm/src/shapes/text_horizontal/emphasis.rs @@ -83,7 +83,7 @@ pub(crate) fn horizontal_emphasis_mark_box( placement: &HorizontalEmphasisPlacement, ) -> skia::Rect { let size = span.font_size * EMPHASIS_FONT_SCALE; - let top = if span.emphasis_side(false) == RubySide::Over { + let top = if span.emphasis_side(false) == AnnotationSide::Over { placement.em_top - span.emphasis_outside_offset(false) - size } else { placement.rect.bottom() + span.emphasis_outside_offset(false) @@ -144,7 +144,7 @@ pub(crate) fn paint_horizontal_emphasis( .filter(|placement| placement.span == range.span && placement.mark == mark) { let mark_x = x + placement.rect.center_x() - mark_width / 2.0; - let mark_y = y + if span.emphasis_side(false) == RubySide::Over { + let mark_y = y + if span.emphasis_side(false) == AnnotationSide::Over { placement.em_top - mark_ink_bottom - span.emphasis_outside_offset(false) } else { placement.rect.bottom() - mark_ink_top + span.emphasis_outside_offset(false) diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs index 49c25920a3..b2524be556 100644 --- a/render-wasm/src/shapes/text_horizontal/justify.rs +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -1,7 +1,12 @@ +// Line adjustment of horizontal CJK text. SkParagraph only expands around +// ideographs, which bunches the space next to kanji. Japanese justification +// (JLREQ §3.8) spreads each line's slack evenly between its characters, and +// Japanese squeezing (oikomi) reduces punctuation aki to keep the next +// character. The other policies justify over their pairwise expansion +// opportunities with bounded amounts. + +use crate::shapes::text_allocation::capped_equal_allocations; use crate::shapes::text_paragraph::LayoutParagraph; -// Justification of horizontal Japanese text (JLREQ §3.8). SkParagraph only -// expands around ideographs, which bunches the space next to kanji; this pass -// spreads each line's slack evenly between its characters instead. use super::*; @@ -11,7 +16,7 @@ const JUSTIFY_EPSILON: f32 = 0.05; /// True for a character that belongs to a Western word: no space opens /// between two of them. fn is_word_char(ch: char) -> bool { - !ch.is_whitespace() && !is_japanese_text_char(ch) && !kinsoku::is_inserted_zero_width(ch) + !ch.is_whitespace() && !is_cjk_text_char(ch) && !line_breaks::is_inserted_zero_width(ch) } /// Per-span letter-spacing (char index in the layout text, amount) that @@ -19,7 +24,7 @@ fn is_word_char(ch: char) -> bool { /// `width`. Every line but the last spreads its slack evenly over the gaps /// between its characters, skipping gaps inside a Western word and the /// zero-width word joiners; warichu notes take none. -pub(crate) fn horizontal_justify_spacing( +pub(crate) fn horizontal_jlreq_justify_spacing( laid_out: &LayoutParagraph, plan: &HorizontalParagraphPlan, width: f32, @@ -36,7 +41,7 @@ pub(crate) fn horizontal_justify_spacing( }; let mut utf16 = range.builder_start; for (index, ch) in text.chars().enumerate() { - if !kinsoku::is_inserted_zero_width(ch) { + if !line_breaks::is_inserted_zero_width(ch) { chars.push((utf16, range.span, index, ch)); } utf16 += ch.len_utf16(); @@ -73,9 +78,9 @@ pub(crate) fn horizontal_justify_spacing( spacing } -/// Policy-based, bounded justification; explicit distribution fills every +/// Pairwise, bounded justification; explicit distribution fills every /// line without a per-gap cap. Annotation and grapheme interiors stay solid. -pub(crate) fn horizontal_policy_justify_spacing( +pub(crate) fn horizontal_pairwise_justify_spacing( paragraph: &Paragraph, laid_out: &LayoutParagraph, plan: &HorizontalParagraphPlan, @@ -190,7 +195,7 @@ struct LineChar { /// Char index in the span's layout text. index: usize, ch: char, - class: Option, + class: Option, font_size: f32, /// A WORD JOINER before it forbids a break. joined: bool, @@ -210,9 +215,9 @@ fn line_chars(paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> Vec(); let last = &chars[unit.end - 1]; let trailing_aki = match last.class { - Some(JapaneseClass::ClosingBracket | JapaneseClass::Comma) => last.font_size / 2.0, + Some(JlreqClass::ClosingBracket | JlreqClass::Comma) => last.font_size / 2.0, _ => 0.0, }; let needed = line.width as f32 + unit_width - trailing_aki - width + JUSTIFY_EPSILON; @@ -453,7 +458,7 @@ mod tests { .sum::(); let last = &chars[unit.end - 1]; let trailing_aki = match last.class { - Some(JapaneseClass::ClosingBracket | JapaneseClass::Comma) => last.font_size / 2.0, + Some(JlreqClass::ClosingBracket | JlreqClass::Comma) => last.font_size / 2.0, _ => 0.0, }; let needed = line.width as f32 + unit_width - trailing_aki - width + JUSTIFY_EPSILON; @@ -534,7 +539,7 @@ mod tests { if window == FULL_WINDOW { full_layouts.set(full_layouts.get() + 1); } - let sheds: Vec<(usize, f32)> = plan.sheds[0] + let sheds: Vec<(usize, f32)> = plan.pair_spacing[0] .iter() .chain(extra.first().into_iter().flatten()) .copied() diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 9e9c4492f8..3d11571a86 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -1,7 +1,9 @@ +// Horizontal CJK layout on top of SkParagraph: the policy's layout texts +// (break joiners and spacing characters), punctuation and inter-script +// spacing, offset maps between source, layout and builder text, line +// adjustment, and the ruby, emphasis and warichu painters. + use crate::shapes::text_paragraph::{LayoutParagraph, SpacedParagraphBuilder}; -// Horizontal Japanese layout on top of SkParagraph: the kinsoku layout -// texts, punctuation aki, offset maps between source, layout and builder -// text, and the ruby, emphasis and warichu painters. mod emphasis; mod justify; @@ -20,17 +22,19 @@ pub(crate) use ruby::*; pub(crate) use warichu::*; use super::text::{add_text_with_tabs, Paragraph, TextContent, TextSpan}; -use super::text_japanese::*; +use super::text_attrs::*; +use super::text_ruby::*; use super::text_vertical::{ is_upright_char, shape_segment_with_fallbacks, single_glyph_blob, span_font_families, ShapedRun, }; +use super::text_warichu::*; use crate::globals::get_resources; use crate::math::Point; -use crate::shapes::japanese::{ - aki_class, is_japanese_text_char, pair_rule, punctuation_aki_sheds, AkiShed, JapaneseClass, +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::{kinsoku, merge_fills}; +use crate::shapes::{line_breaks, merge_fills}; use crate::utils::{get_fallback_fonts, get_font_collection}; use skia_safe::{ self as skia, @@ -42,17 +46,15 @@ use skia_safe::{ }; /// True when a paragraph with layout texts `texts` needs the Japanese passes: -/// it has ruby or a Japanese character. +/// 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.children().iter().any(TextSpan::has_ruby) - || texts - .iter() - .any(|text| text.chars().any(is_japanese_text_char))) + || texts.iter().any(|text| text.chars().any(is_cjk_text_char))) } #[cfg(test)] -pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku::OffsetMap) { +pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, line_breaks::OffsetMap) { layout_span_texts_with_breaks(paragraph, &[], &[]) } @@ -60,7 +62,7 @@ fn layout_span_texts_with_breaks( paragraph: &Paragraph, emergency: &[usize], forced: &[usize], -) -> (Vec, kinsoku::OffsetMap) { +) -> (Vec, line_breaks::OffsetMap) { let texts: Vec = paragraph .children() .iter() @@ -75,12 +77,26 @@ fn layout_span_texts_with_breaks( .map(|span| span.has_ruby().then(Vec::new)) .collect(); let shifted = - kinsoku::apply_policy_with_breaks(&texts, &ruby_breaks, policy, emergency, forced); + line_breaks::apply_policy_with_breaks(&texts, &ruby_breaks, policy, emergency, forced); if let Some((shifted, map)) = shifted { return (shifted, map); } } - (texts, kinsoku::OffsetMap::default()) + (texts, line_breaks::OffsetMap::default()) +} + +/// Advance changes between adjacent characters of the layout `texts`, as +/// (char index, amount) per span: positive narrows the character, negative +/// widens it. Japanese sheds JLREQ punctuation aki; the other policies use +/// their pairwise punctuation compression and script spacing. +pub(crate) fn horizontal_pair_spacing( + paragraph: &Paragraph, + texts: &[String], +) -> Vec> { + if paragraph.layout_policy() != TextLayoutPolicy::Japanese { + return horizontal_pairwise_spacing(paragraph, texts); + } + jlreq_aki_sheds(paragraph, texts) } /// (char index, amount) per span of the layout `texts` for the characters @@ -88,24 +104,18 @@ fn layout_span_texts_with_breaks( /// sheds half of its own em, and the character before an opening bracket /// gives up half of the bracket's em. Inserted word joiners are transparent; /// `palt` spans set punctuation proportionally. -pub(crate) fn horizontal_aki_sheds( - paragraph: &Paragraph, - texts: &[String], -) -> Vec> { - if paragraph.layout_policy() != TextLayoutPolicy::Japanese { - return horizontal_policy_spacing(paragraph, texts); - } +fn jlreq_aki_sheds(paragraph: &Paragraph, texts: &[String]) -> Vec> { let spans = paragraph.children(); let half_em = |span: usize| spans.get(span).map_or(0.0, |span| span.font_size * 0.5); // (span, char index in the span text) of every real character. let mut chars: Vec<(usize, usize)> = Vec::new(); - let mut classes: Vec> = Vec::new(); + let mut classes: Vec> = Vec::new(); for (span_index, text) in texts.iter().enumerate() { let span = spans.get(span_index); let ruby_base = span.is_some_and(TextSpan::has_ruby); let proportional = span.is_some_and(|span| span.font_features == FontFeatures::Palt); for (index, ch) in text.chars().enumerate() { - if kinsoku::is_inserted_zero_width(ch) { + if line_breaks::is_inserted_zero_width(ch) { continue; } chars.push((span_index, index)); @@ -125,13 +135,13 @@ pub(crate) fn horizontal_aki_sheds( /// Signed advance changes: positive removes punctuation space, negative adds /// the regional Han/Latin gap. Source and builder text stay unchanged. -fn horizontal_policy_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec> { +fn horizontal_pairwise_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec> { let mut result = vec![Vec::new(); texts.len()]; let policy = paragraph.layout_policy(); let mut previous: Option<(usize, usize, char)> = None; for (span, text) in texts.iter().enumerate() { for (index, ch) in text.chars().enumerate() { - if kinsoku::is_inserted_zero_width(ch) { + if line_breaks::is_inserted_zero_width(ch) { continue; } if let Some((before_span, before_index, before)) = previous { @@ -160,7 +170,7 @@ fn horizontal_policy_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec, pub(crate) offsets: HorizontalOffsets, - pub(crate) sheds: Vec>, + pub(crate) pair_spacing: Vec>, pub(crate) ruby_spacing: HorizontalRubySpacing, emergency_breaks: Vec, overflow_width: Option, @@ -457,13 +467,13 @@ impl HorizontalParagraphPlan { fn with_breaks(paragraph: &Paragraph, emergency: &[usize], forced: &[usize]) -> Self { let (texts, offset_map) = layout_span_texts_with_breaks(paragraph, emergency, forced); - let sheds = horizontal_aki_sheds(paragraph, &texts); + let pair_spacing = horizontal_pair_spacing(paragraph, &texts); let ruby_spacing = horizontal_ruby_spacing(paragraph, &texts, &offset_map); let offsets = HorizontalOffsets::from_layout_texts(paragraph, &texts, offset_map); Self { texts, offsets, - sheds, + pair_spacing, ruby_spacing, emergency_breaks: emergency.to_vec(), overflow_width: None, diff --git a/render-wasm/src/shapes/text_horizontal/offsets.rs b/render-wasm/src/shapes/text_horizontal/offsets.rs index 8350dcf59d..29a5358a25 100644 --- a/render-wasm/src/shapes/text_horizontal/offsets.rs +++ b/render-wasm/src/shapes/text_horizontal/offsets.rs @@ -20,7 +20,7 @@ pub(crate) struct HorizontalSpanRange { /// kinsoku pass; reuse it for every lookup in the paragraph. pub(crate) struct HorizontalOffsets { /// Map between the transformed text and the kinsoku-adjusted text. - pub(crate) offset_map: kinsoku::OffsetMap, + pub(crate) offset_map: line_breaks::OffsetMap, pub(crate) ranges: Vec, /// Transformed-text UTF-16 offset of every source character boundary. boundaries: Vec, @@ -37,7 +37,7 @@ impl HorizontalOffsets { pub(super) fn from_layout_texts( paragraph: &Paragraph, span_texts: &[String], - offset_map: kinsoku::OffsetMap, + offset_map: line_breaks::OffsetMap, ) -> Self { let ranges = span_ranges(paragraph, span_texts, &offset_map); Self { @@ -162,7 +162,7 @@ pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset fn span_ranges( paragraph: &Paragraph, span_texts: &[String], - offset_map: &kinsoku::OffsetMap, + offset_map: &line_breaks::OffsetMap, ) -> Vec { let mut builder_cursor = 0usize; let mut builder_byte_cursor = 0usize; @@ -180,9 +180,9 @@ fn span_ranges( let source_end = offset_map.to_original(shifted_end); let warichu = span.is_warichu(); let leading_break = - usize::from(warichu && text.starts_with(kinsoku::FORCED_LINE_BREAK)); + usize::from(warichu && text.starts_with(line_breaks::FORCED_LINE_BREAK)); builder_cursor += leading_break; - builder_byte_cursor += leading_break * kinsoku::FORCED_LINE_BREAK.len_utf8(); + builder_byte_cursor += leading_break * line_breaks::FORCED_LINE_BREAK.len_utf8(); let builder_start = builder_cursor; let (style_anchor_start, style_anchor_utf16) = if warichu { ( diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index edf09fbf60..7991c49c9e 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -80,8 +80,9 @@ pub(crate) struct HorizontalRubySpacing { /// the character, searching from (span, index) in `step` direction and /// skipping inserted word joiners. fn layout_neighbour(texts: &[String], span: usize, forward: bool) -> Option<(usize, usize, char)> { - let real = - |(index, ch): (usize, char)| (!kinsoku::is_inserted_zero_width(ch)).then_some((index, ch)); + let real = |(index, ch): (usize, char)| { + (!line_breaks::is_inserted_zero_width(ch)).then_some((index, ch)) + }; if forward { texts .iter() @@ -121,7 +122,7 @@ fn layout_neighbour(texts: &[String], span: usize, forward: bool) -> Option<(usi pub(crate) fn horizontal_ruby_spacing( paragraph: &Paragraph, texts: &[String], - offset_map: &kinsoku::OffsetMap, + offset_map: &line_breaks::OffsetMap, ) -> HorizontalRubySpacing { if !paragraph.children().iter().any(TextSpan::has_ruby) { return HorizontalRubySpacing::empty(texts.len()); @@ -150,7 +151,7 @@ impl HorizontalRubySpacing { pub(super) fn horizontal_ruby_spacing_with( paragraph: &Paragraph, texts: &[String], - offset_map: &kinsoku::OffsetMap, + offset_map: &line_breaks::OffsetMap, font_provider: &skia::textlayout::TypefaceFontProvider, fallback_families: &FallbackFonts, ) -> HorizontalRubySpacing { @@ -661,7 +662,7 @@ impl RubyLine<'_> { /// Baseline of the line that attaches its ink to the base: above the base /// em for over ruby, below the base rect for under ruby. pub(super) fn baseline(&self, y: f32) -> f32 { - let over = self.span.ruby_side == RubySide::Over; + let over = self.span.ruby_side == AnnotationSide::Over; let edge = ruby_line_ink_edge(self.shaped, self.glyphs, over); if over { y + self.em_top - edge - self.span.ruby_decoration_offset(false) diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 7f9f5463b7..3fb3f8cdfb 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -1,4 +1,4 @@ -use super::justify::{horizontal_justify_spacing, horizontal_squeeze_sheds}; +use super::justify::{horizontal_jlreq_justify_spacing, horizontal_squeeze_sheds}; use super::ruby::{ horizontal_ruby_paint, horizontal_ruby_spacing_with, horizontal_ruby_targets, ruby_glyph_refs, split_counts_by_extent, RubyGlyphRef, RubyLine, @@ -56,7 +56,7 @@ fn cjk_layout_paragraph( &mut builder, span, &plan.texts[index], - &plan.sheds[index], + &plan.pair_spacing[index], &plan.ruby_spacing.adjustments[index], &style, fonts, @@ -88,8 +88,8 @@ fn horizontal_chinese_regions_apply_compression_across_styles() { KoreanWrap::Syllable, 1000.0, ); - assert!(!cn.sheds[0].is_empty()); - assert!(tw.sheds.iter().all(Vec::is_empty)); + assert!(!cn.pair_spacing[0].is_empty()); + assert!(tw.pair_spacing.iter().all(Vec::is_empty)); assert!((tw_layout.longest_line() - cn_layout.longest_line() - 10.0).abs() < 0.1); assert_eq!(cn.texts.concat().replace('\u{2060}', ""), "文,。文"); } @@ -256,14 +256,14 @@ fn horizontal_cjk_justification_is_bounded_and_word_mode_only_expands_spaces() { let (paragraph, plan, layout) = cjk_horizontal_layout(&["文文", "文文"], "zh-Hant-TW", KoreanWrap::Syllable, 52.0); let extras = - super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 52.0); + super::justify::horizontal_pairwise_justify_spacing(¶graph, &layout, &plan, 52.0); assert!(extras[1].is_empty(), "last line stays unexpanded"); let total: f32 = extras[0].iter().map(|(_, gap)| *gap).sum(); assert!(total > 9.0 && total <= 10.0); let (paragraph, plan, layout) = cjk_horizontal_layout(&["한 한 한 한"], "ko", KoreanWrap::KeepWords, 72.0); let extras = - super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 72.0); + super::justify::horizontal_pairwise_justify_spacing(¶graph, &layout, &plan, 72.0); assert!(!extras[0].is_empty()); for (at, amount) in &extras[0] { assert_eq!(plan.texts[0].chars().nth(*at), Some(' ')); @@ -272,7 +272,7 @@ fn horizontal_cjk_justification_is_bounded_and_word_mode_only_expands_spaces() { let (paragraph, plan, layout) = cjk_horizontal_layout(&["한한한한"], "ko", KoreanWrap::KeepWords, 52.0); assert!( - super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 52.0) + super::justify::horizontal_pairwise_justify_spacing(¶graph, &layout, &plan, 52.0) .iter() .all(Vec::is_empty) ); @@ -295,7 +295,7 @@ fn distribution_fills_the_last_line_between_hangul_graphemes_across_styles() { paragraph.set_language("ko".to_owned()); paragraph.set_korean_wrap(KoreanWrap::KeepWords); let extras = - super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 310.0); + super::justify::horizontal_pairwise_justify_spacing(¶graph, &layout, &plan, 310.0); let total: f32 = extras.iter().flatten().map(|(_, amount)| amount).sum(); assert!((layout.longest_line() + total - 310.0).abs() < 0.1); let gaps: Vec<_> = extras.iter().flatten().map(|(_, amount)| *amount).collect(); @@ -335,7 +335,7 @@ fn distribution_keeps_graphemes_nonbreaking_pairs_and_ruby_bases_solid() { paragraph.set_korean_wrap(KoreanWrap::KeepWords); let (plan, layout) = cjk_layout_paragraph(¶graph, 310.0, &fonts); let extras = - super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 310.0); + super::justify::horizontal_pairwise_justify_spacing(¶graph, &layout, &plan, 310.0); assert_eq!( extras.iter().map(Vec::len).sum::(), expected_gaps, @@ -356,7 +356,7 @@ fn distribution_keeps_graphemes_nonbreaking_pairs_and_ruby_bases_solid() { ); let (plan, layout) = cjk_layout_paragraph(¶graph, 310.0, &fonts); assert!( - super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 310.0) + super::justify::horizontal_pairwise_justify_spacing(¶graph, &layout, &plan, 310.0) .iter() .all(Vec::is_empty) ); @@ -370,7 +370,7 @@ fn chinese_proportional_punctuation_is_not_compressed_twice() { let mut paragraph = make_paragraph(vec![span], 0.0); paragraph.set_language("zh-Hans-CN".into()); let plan = HorizontalParagraphPlan::new(¶graph); - assert!(plan.sheds.iter().all(Vec::is_empty)); + assert!(plan.pair_spacing.iter().all(Vec::is_empty)); } #[test] @@ -389,7 +389,7 @@ fn untagged_cjk_keeps_plain_layout() { let (texts, offsets) = paragraph.layout_span_texts(); assert_eq!(texts, vec![text.to_string()]); assert!(offsets.is_empty()); - assert!(horizontal_aki_sheds(¶graph, &texts)[0].is_empty()); + assert!(horizontal_pair_spacing(¶graph, &texts)[0].is_empty()); } } @@ -407,7 +407,7 @@ fn explicit_profiles_use_distinct_rules_across_styles() { assert_eq!(map.to_original(map.to_shifted(offset)), offset); } let plan = HorizontalParagraphPlan::new(¶graph); - assert!(plan.sheds.iter().all(Vec::is_empty)); + assert!(plan.pair_spacing.iter().all(Vec::is_empty)); let mut paragraph = make_paragraph(vec![make_span("한", 0.0), make_span("국어 문장", 0.0)], 0.0); @@ -463,7 +463,7 @@ fn same_paragraph_wraps_differently_by_profile_in_both_orientations() { &mut builder, span, &plan.texts[0], - &plan.sheds[0], + &plan.pair_spacing[0], &[], &style, &fonts, @@ -677,8 +677,8 @@ fn layout_span_texts_sets_western_spaces_inside_japanese_text() { texts, vec![format!( "日本{}hello{}world", - kinsoku::WESTERN_WORD_SPACE, - kinsoku::WESTERN_WORD_SPACE + line_breaks::WESTERN_WORD_SPACE, + line_breaks::WESTERN_WORD_SPACE )] ); } @@ -991,12 +991,12 @@ fn warichu_sub_lines_paint_on_one_line() { } #[test] -fn horizontal_aki_sheds_follow_punctuation_pairs() { +fn horizontal_pair_spacing_follow_punctuation_pairs() { init_state(); let texts = vec!["あ。」い、".to_string(), "「「う".to_string()]; let paragraph = make_paragraph(texts.iter().map(|text| make_span(text, 0.0)).collect(), 0.0); assert_eq!( - horizontal_aki_sheds(¶graph, &texts), + horizontal_pair_spacing(¶graph, &texts), vec![vec![(1, 8.0), (4, 8.0)], vec![(0, 8.0)]], "。 before 」, 、 before 「 across spans, and 「 before 「" ); @@ -1010,20 +1010,20 @@ fn leading_aki_shed_uses_the_bracket_font_size() { large.font_size = 32.0; let paragraph = make_paragraph(vec![make_span("「", 0.0), large], 0.0); assert_eq!( - horizontal_aki_sheds(¶graph, &texts), + horizontal_pair_spacing(¶graph, &texts), vec![vec![(0, 16.0)], vec![]], "the 16px 「 gives up half of the next 32px bracket's em" ); } #[test] -fn horizontal_aki_sheds_skip_curly_quotes() { +fn horizontal_pair_spacing_skip_curly_quotes() { init_state(); for text in ["“‘Hi’”", "あ“い”。", "あ‘い’、"] { let texts = vec![text.to_string()]; let paragraph = make_paragraph(vec![make_span(text, 0.0)], 0.0); assert_eq!( - horizontal_aki_sheds(¶graph, &texts), + horizontal_pair_spacing(¶graph, &texts), vec![Vec::<(usize, f32)>::new()], "no aki shed in {text:?}" ); @@ -1031,12 +1031,12 @@ fn horizontal_aki_sheds_skip_curly_quotes() { } #[test] -fn horizontal_aki_sheds_skip_word_joiners_and_palt() { +fn horizontal_pair_spacing_skip_word_joiners_and_palt() { init_state(); let joined = vec!["。\u{2060}」".to_string()]; let paragraph = make_paragraph(vec![make_span(&joined[0], 0.0)], 0.0); assert_eq!( - horizontal_aki_sheds(¶graph, &joined), + horizontal_pair_spacing(¶graph, &joined), vec![vec![(0, 8.0)]] ); @@ -1044,7 +1044,7 @@ fn horizontal_aki_sheds_skip_word_joiners_and_palt() { palt.font_features = FontFeatures::Palt; let paragraph = make_paragraph(vec![palt], 0.0); assert_eq!( - horizontal_aki_sheds(¶graph, &["。」".to_string()]), + horizontal_pair_spacing(¶graph, &["。」".to_string()]), vec![Vec::<(usize, f32)>::new()] ); } @@ -1360,7 +1360,7 @@ fn latin_readings_share_one_baseline_whatever_their_letters() { ) }; let base = skia::Rect::from_xywh(0.0, 0.0, EM, EM); - for side in [RubySide::Over, RubySide::Under] { + for side in [AnnotationSide::Over, AnnotationSide::Under] { let baseline = |text: &str| { let span = TextSpan { ruby: text.to_string(), @@ -1388,7 +1388,7 @@ fn latin_readings_share_one_baseline_whatever_their_letters() { (descender - plain).abs() < 0.01, "{side:?}: yu at {descender}, lia at {plain}" ); - if side == RubySide::Over { + if side == AnnotationSide::Over { let mut bounds = [skia::Rect::default()]; shaped[0] .font @@ -1426,10 +1426,10 @@ fn emphasis_position_overrides_the_language_and_stacks_on_its_own_side() { let plain = super::emphasis::horizontal_emphasis_mark_box(&span, &placement); assert_eq!(plain.top() >= placement.rect.bottom(), under); span.ruby = "zhōng wén".to_string(); - for side in [RubySide::Over, RubySide::Under] { + for side in [AnnotationSide::Over, AnnotationSide::Under] { span.ruby_side = side; let stacked = super::emphasis::horizontal_emphasis_mark_box(&span, &placement); - let expected = if (side == RubySide::Under) == under { + let expected = if (side == AnnotationSide::Under) == under { span.ruby_font_size() } else { 0.0 @@ -1708,7 +1708,7 @@ fn horizontal_justify_spreads_every_line_evenly() { &mut builder, ¶graph.children()[0], &plan.texts[0], - &plan.sheds[0], + &plan.pair_spacing[0], extra, &style, &fonts, @@ -1719,7 +1719,7 @@ fn horizontal_justify_spreads_every_line_evenly() { }; let natural = lay_out(&[]); - let spacing = horizontal_justify_spacing(&natural, &plan, width); + let spacing = horizontal_jlreq_justify_spacing(&natural, &plan, width); let justified = lay_out(&spacing[0]); let natural_lines = natural.get_line_metrics(); @@ -1774,7 +1774,7 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() { // Seven characters fit; the eighth overflows by less than the 、 aki. let width = advance * 7.6; let lay_out = |extra: &[Vec<(usize, f32)>], window: std::ops::Range| { - let sheds: Vec<(usize, f32)> = plan.sheds[0] + let sheds: Vec<(usize, f32)> = plan.pair_spacing[0] .iter() .chain(extra.first().into_iter().flatten()) .copied() @@ -1901,7 +1901,7 @@ fn horizontal_indivisible_punctuation_unit_overflows_a_narrow_box() { #[test] fn japanese_narrow_box_keeps_ruby_bases_whole_and_respects_kinsoku() { - use crate::shapes::kinsoku::forbidden_at_line_start; + use crate::shapes::jlreq::forbidden_at_line_start; use crate::shapes::text_vertical::test_support::provider; init_state(); let mut fonts = skia::textlayout::FontCollection::new(); diff --git a/render-wasm/src/shapes/text_policy.rs b/render-wasm/src/shapes/text_policy.rs index 4294d9c512..b9c3bb2e2c 100644 --- a/render-wasm/src/shapes/text_policy.rs +++ b/render-wasm/src/shapes/text_policy.rs @@ -2,7 +2,7 @@ //! Regional presets use CLREQ (2026-10-07); Korean restrictions use KLREQ //! (2026-03-21). These are named presets, not universal regional mandates. -use super::japanese::break_allowed_between; +use super::jlreq::break_allowed_between; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -143,8 +143,8 @@ impl TextLayoutPolicy { && !closing(after) && !(self.is_korean() && "‐‑–-・·々〻ー".contains(after)) && !(self == Self::Japanese - && (crate::shapes::kinsoku::forbidden_at_line_start(after) - || crate::shapes::kinsoku::forbidden_at_line_end(before))) + && (crate::shapes::jlreq::forbidden_at_line_start(after) + || crate::shapes::jlreq::forbidden_at_line_end(before))) && !(before == after && matches!(after, '…' | '‥' | '—' | '―')) && !("$$£€₩¥¥₽₺₫++--−".contains(before) && numeric(after)) && !(numeric(before) && "%%‰℃℉°".contains(after)) diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_ruby.rs similarity index 52% rename from render-wasm/src/shapes/text_japanese.rs rename to render-wasm/src/shapes/text_ruby.rs index 37cfe2ea22..875e5f0075 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_ruby.rs @@ -1,44 +1,8 @@ -// Japanese layout values and rules shared by the horizontal and vertical -// engines: span attributes, annotation scales, emphasis eligibility, the -// warichu split and the ruby overhang, distribution and spreading rules. +//! Ruby (furigana, Pinyin) piece, distribution and overhang rules shared by +//! the horizontal and vertical engines (JLREQ §3.3). -use crate::shapes::japanese::{classify, JapaneseClass}; -use crate::shapes::kinsoku; - -pub const WARICHU_FONT_SCALE: f32 = 0.5; -pub const EMPHASIS_FONT_SCALE: f32 = 0.5; -pub(crate) fn emphasis_char_allowed(character: char) -> bool { - !character.is_whitespace() - && !crate::shapes::japanese::classify(character).is_emphasis_prohibited() -} - -/// Char index where a warichu run splits into its two sub-lines: the -/// midpoint (first line longer), moved to the nearest split that keeps -/// kinsoku. At equal distance a forward move wins, pulling the mark up into -/// the first sub-line (jlreq). Falls back to the midpoint. -pub(crate) fn warichu_split_chars(text: &str) -> usize { - let chars: Vec = text.chars().collect(); - let n = chars.len(); - let mid = n.div_ceil(2); - let valid = |split: usize| { - split >= 1 - && split < n - && !kinsoku::forbidden_at_line_start(chars[split]) - && !kinsoku::forbidden_at_line_end(chars[split - 1]) - }; - if valid(mid) { - return mid; - } - for distance in 1..n { - if valid(mid + distance) { - return mid + distance; - } - if mid > distance && valid(mid - distance) { - return mid - distance; - } - } - mid -} +use crate::shapes::jlreq::{classify, JlreqClass}; +use crate::shapes::{RubyAlign, RubyOverhang}; /// Room a long reading may overhang one neighbouring character (JLREQ /// §3.3.8, Fig 135): up to one ruby character, and half the neighbour, over @@ -55,13 +19,13 @@ pub(crate) fn ruby_overhang_room( let overhangable = neighbour.is_some_and(|ch| { matches!( classify(ch), - JapaneseClass::Hiragana - | JapaneseClass::Katakana - | JapaneseClass::SmallKana - | JapaneseClass::ProlongedSoundMark - | JapaneseClass::Comma - | JapaneseClass::FullStop - | JapaneseClass::MiddleDot + JlreqClass::Hiragana + | JlreqClass::Katakana + | JlreqClass::SmallKana + | JlreqClass::ProlongedSoundMark + | JlreqClass::Comma + | JlreqClass::FullStop + | JlreqClass::MiddleDot ) }); if policy == RubyOverhang::Auto && overhangable && !neighbour_has_ruby { @@ -183,295 +147,10 @@ pub(crate) fn distribute_ruby_pieces( } } -/// Divide `amount` equally across capped opportunities, redistributing the -/// remainder whenever one opportunity reaches its cap. -/// Amounts below this count as zero when dividing adjustments. -const ALLOCATION_EPSILON: f32 = 0.0001; - -pub(crate) fn capped_equal_allocations( - capacities: &[(usize, f32)], - amount: f32, -) -> Vec<(usize, f32)> { - let mut allocations: Vec<(usize, f32)> = capacities.iter().map(|(i, _)| (*i, 0.0)).collect(); - let mut remaining = amount.max(0.0); - while remaining > ALLOCATION_EPSILON { - let active: Vec = capacities - .iter() - .enumerate() - .filter_map(|(slot, (_, cap))| { - (allocations[slot].1 + ALLOCATION_EPSILON < *cap).then_some(slot) - }) - .collect(); - if active.is_empty() { - break; - } - let share = remaining / active.len() as f32; - let mut used = 0.0; - for slot in active { - let cap = capacities[slot].1; - let delta = share.min(cap - allocations[slot].1); - allocations[slot].1 += delta; - used += delta; - } - if used <= ALLOCATION_EPSILON { - break; - } - remaining -= used; - } - allocations -} - -/// Block flow direction of a paragraph. Horizontal uses skparagraph; -/// vertical-rl uses the custom vertical pass: columns top to bottom, -/// advancing right to left. -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum WritingMode { - #[default] - HorizontalTb, - VerticalRl, -} - -/// How a line slightly too long for its box is fitted (JLREQ §3.8.1; Adobe's -/// Kinsoku Type). Squeezing reduces punctuation aki to keep the next -/// character; pushing out moves characters to the next line. Alignment then -/// decides what happens to the space a line leaves over. -// The variants are Adobe's Kinsoku Type values and the JS keys. -#[allow(clippy::enum_variant_names)] -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum LineAdjustment { - /// Squeeze first; push out when the aki cannot absorb the overflow. - #[default] - PushInFirst, - /// Push out first; squeeze only when kinsoku leaves no other break. - PushOutFirst, - /// Never squeeze. - PushOutOnly, -} - -/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs -/// sideways, `Upright` keeps every character upright. Ignored in -/// horizontal writing. -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum TextOrientation { - #[default] - Mixed, - Upright, -} - -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum TextCombineUpright { - #[default] - None, - All, - /// Runs of up to 4 ASCII or full-width digits combine into one upright - /// cell, so a lone digit stands upright (CSS `digits`). - Digits, - /// Like `Digits`, for runs of up to 2 (CSS `digits 2`). - Digits2, - /// Like `Digits`, for runs of up to 3. - Digits3, -} - -impl TextCombineUpright { - /// Longest digit run that combines, when digits mode is active. - pub fn digits_max(self) -> Option { - match self { - TextCombineUpright::Digits => Some(4), - TextCombineUpright::Digits2 => Some(2), - TextCombineUpright::Digits3 => Some(3), - _ => None, - } - } -} - -/// Independent emphasis positions in the two writing directions. -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum TextEmphasisPosition { - #[default] - Auto, - OverRight, - OverLeft, - UnderRight, - UnderLeft, -} - -impl TextEmphasisPosition { - pub fn side(self, vertical: bool, chinese: bool) -> RubySide { - let under = if vertical { - matches!(self, Self::OverLeft | Self::UnderLeft) - } else { - matches!(self, Self::UnderRight | Self::UnderLeft) || (self == Self::Auto && chinese) - }; - if under { - RubySide::Under - } else { - RubySide::Over - } - } -} - -/// CSS underline position: horizontal depth and vertical side are independent. -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum TextUnderlinePosition { - #[default] - Auto, - Under, - Left, - Right, - UnderLeft, - UnderRight, -} - -impl TextUnderlinePosition { - pub fn side(self) -> RubySide { - if matches!(self, Self::Right | Self::UnderRight) { - RubySide::Over - } else { - RubySide::Under - } - } - - pub fn below_ink(self, chinese: bool) -> bool { - matches!(self, Self::Under | Self::UnderLeft | Self::UnderRight) - || (self == Self::Auto && chinese) - } -} - -/// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS -/// `text-emphasis-style`. Position is a separate style with a language default. -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum TextEmphasis { - #[default] - None, - FilledDot, - OpenDot, - FilledCircle, - OpenCircle, - FilledSesame, - OpenSesame, -} - -impl TextEmphasis { - pub fn is_none(self) -> bool { - matches!(self, TextEmphasis::None) - } - - /// The glyph drawn as the emphasis mark, following the CSS - /// `text-emphasis-style` character mapping. - pub fn mark_char(self) -> Option { - match self { - TextEmphasis::None => None, - TextEmphasis::FilledDot => Some('•'), - TextEmphasis::OpenDot => Some('◦'), - TextEmphasis::FilledCircle => Some('●'), - TextEmphasis::OpenCircle => Some('○'), - TextEmphasis::FilledSesame => Some('﹅'), - TextEmphasis::OpenSesame => Some('﹆'), - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum FontFeatures { - #[default] - None, - Palt, - Vpal, -} - -/// Whether ruby and emphasis on the same side stack. `Auto` places emphasis -/// outside over-side ruby and reserves room for both; `None` lets them share -/// one layer. -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum AnnotationClearance { - #[default] - None, - Auto, -} - -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum RubySize { - #[default] - Half, - Third, - Quarter, -} - -impl RubySize { - pub fn scale(self) -> f32 { - match self { - Self::Half => 0.5, - Self::Third => 1.0 / 3.0, - Self::Quarter => 0.25, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum RubyAlign { - #[default] - SpaceAround, - Center, - Start, - SpaceBetween, -} - -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum RubyOverhang { - #[default] - Auto, - None, -} - -#[derive(Debug, PartialEq, Clone, Copy, Default)] -pub enum RubySide { - #[default] - Over, - Under, -} - -impl AnnotationClearance { - pub fn is_auto(self) -> bool { - matches!(self, AnnotationClearance::Auto) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn warichu_split_balances_and_respects_kinsoku() { - // Balanced midpoint when nothing forbids it, first line longer. - assert_eq!(warichu_split_chars("あいうえおか"), 3); - assert_eq!(warichu_split_chars("あいうえお"), 3); - // A comma at the midpoint may end the first sub-line... - assert_eq!(warichu_split_chars("あい、うえ"), 3); - // ...but must not start the second one: the split moves forward. - assert_eq!(warichu_split_chars("あいう、えお"), 4); - // An opening bracket must not end the first sub-line. - assert_eq!(warichu_split_chars("あい「うえお"), 4); - // Pathological all-forbidden text keeps the midpoint. - assert_eq!(warichu_split_chars("、、、、"), 2); - } - - #[test] - fn emphasis_excludes_whitespace_and_japanese_punctuation() { - for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars() - { - assert!( - !emphasis_char_allowed(character), - "emphasis must skip {character:?}" - ); - } - for character in "漢あA1・!?".chars() { - assert!( - emphasis_char_allowed(character), - "emphasis should mark {character:?}" - ); - } - } - #[test] fn ruby_shorter_than_base_distributes_evenly() { // A ruby line of 2 x 50 equals the base extent (100): one glyph per diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs index dde5cbc2fb..20e159795a 100644 --- a/render-wasm/src/shapes/text_vertical/annotations.rs +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -7,12 +7,13 @@ use std::collections::HashMap; use skia_safe::{self as skia, Font}; -use crate::shapes::text_japanese::{ - distribute_ruby_pieces, emphasis_char_allowed, long_ruby_gap, ruby_overhang_room, - ruby_overhangs, ruby_pieces, EMPHASIS_FONT_SCALE, +use crate::shapes::text_attrs::{emphasis_char_allowed, EMPHASIS_FONT_SCALE}; +use crate::shapes::text_ruby::{ + distribute_ruby_pieces, long_ruby_gap, ruby_overhang_room, ruby_overhangs, ruby_pieces, }; use crate::shapes::{ - AppliedTextTransform, FontFeatures, Paragraph, RubyOverhang, RubySide, TextContent, TextSpan, + AnnotationSide, AppliedTextTransform, FontFeatures, Paragraph, RubyOverhang, TextContent, + TextSpan, }; use super::cells::Fonts; @@ -52,13 +53,13 @@ pub struct RubyCell { pub font_size: f32, pub base_font_size: f32, pub outside_offset: f32, - pub side: RubySide, + pub side: AnnotationSide, pub paint: usize, } impl RubyCell { pub(super) fn strip_x(&self, column: &VerticalColumn) -> f32 { - let sign = if self.side == RubySide::Over { + let sign = if self.side == AnnotationSide::Over { 1.0 } else { -1.0 @@ -80,7 +81,7 @@ pub struct EmphasisMark { pub font_size: f32, /// Cross-axis offset past a stacked ruby layer. pub outside_offset: f32, - pub side: RubySide, + pub side: AnnotationSide, } /// Cross-axis start of a vertical ruby strip. Ruby attaches to the edge of the @@ -90,12 +91,12 @@ pub(super) fn ruby_strip_x( column: &VerticalColumn, ruby_font_size: f32, base_font_size: f32, - side: RubySide, + side: AnnotationSide, ) -> f32 { let base_center = column_base_center(column); match side { - RubySide::Over => base_center + base_font_size / 2.0, - RubySide::Under => base_center - base_font_size / 2.0 - ruby_font_size, + AnnotationSide::Over => base_center + base_font_size / 2.0, + AnnotationSide::Under => base_center - base_font_size / 2.0 - ruby_font_size, } } @@ -505,7 +506,7 @@ pub(super) fn layout_ruby( font_size: span.ruby_font_size(), base_font_size: span.font_size, outside_offset: span.ruby_decoration_offset(true), - side: RubySide::Over, + side: AnnotationSide::Over, paint: first.paint, }); continue; @@ -725,7 +726,7 @@ pub(super) fn layout_emphasis( pub(super) fn emphasis_mark_center(layout: &VerticalLayout, mark: &EmphasisMark) -> (f32, f32) { let cell = &layout.cells[mark.cell]; let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; - let sign = if mark.side == RubySide::Over { + let sign = if mark.side == AnnotationSide::Over { 1.0 } else { -1.0 @@ -1040,7 +1041,7 @@ mod tests { crate::shapes::TextEmphasisPosition::OverLeft, crate::shapes::TextEmphasisPosition::UnderRight, ] { - for side in [RubySide::Over, RubySide::Under] { + for side in [AnnotationSide::Over, AnnotationSide::Under] { let mut content = ruby_content("你好", "nǐ hǎo", 400.0); content.paragraphs_mut()[0].set_language("zh-Hans".to_string()); let span = &mut content.paragraphs_mut()[0].children_mut()[0]; @@ -1055,7 +1056,10 @@ mod tests { for mark in &layout.emphasis_marks { let column = &layout.columns[layout.cells[mark.cell].column]; let (x, _) = emphasis_mark_center(&layout, mark); - assert_eq!(x < column_base_center(column), mark.side == RubySide::Under); + assert_eq!( + x < column_base_center(column), + mark.side == AnnotationSide::Under + ); assert!( (mark.outside_offset - if same_side { ruby_size } else { 0.0 }).abs() < 0.01 @@ -1105,7 +1109,7 @@ mod tests { fn underline_ruby_and_emphasis_fit_in_one_automatic_gutter() { let mut content = ruby_content("你好", "nǐ hǎo", 400.0); let span = &mut content.paragraphs_mut()[0].children_mut()[0]; - span.ruby_side = RubySide::Under; + span.ruby_side = AnnotationSide::Under; span.text_emphasis = TextEmphasis::FilledDot; span.text_emphasis_position = crate::shapes::TextEmphasisPosition::UnderLeft; span.text_decoration = Some(crate::shapes::TextDecoration::UNDERLINE); @@ -1241,7 +1245,7 @@ mod tests { let mut content = ruby_content("AB", "ab", 400.0); let span = &mut content.paragraphs_mut()[0].children_mut()[0]; span.ruby_size = RubySize::Quarter; - span.ruby_side = RubySide::Under; + span.ruby_side = AnnotationSide::Under; span.annotation_clearance = AnnotationClearance::Auto; let layout = layout_content(&content, 400.0); @@ -1251,7 +1255,7 @@ mod tests { assert!((ruby.font_size - 5.0).abs() < 0.001); assert!((column.base_offset - 5.0).abs() < 0.001); assert!((column.width - column.base_width - 5.0).abs() < 0.001); - assert_eq!(ruby.side, RubySide::Under); + assert_eq!(ruby.side, AnnotationSide::Under); } #[test] @@ -1357,8 +1361,11 @@ mod tests { base_width: 24.0, }; - assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Over), 40.0); - assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Under), 12.0); + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, AnnotationSide::Over), 40.0); + assert_eq!( + ruby_strip_x(&column, 8.0, 20.0, AnnotationSide::Under), + 12.0 + ); } #[test] diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index 16381769f7..f0f70937e1 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -8,12 +8,12 @@ use unicode_segmentation::UnicodeSegmentation; use skia_safe::{textlayout::TypefaceFontProvider, FontMgr, Typeface}; use crate::render::FallbackFonts; -use crate::shapes::japanese::{classify, JapaneseClass}; -use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; -use crate::shapes::text_japanese::{warichu_split_chars, WARICHU_FONT_SCALE}; +use crate::shapes::jlreq::{classify, JlreqClass}; +use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start}; +use crate::shapes::text_warichu::{warichu_split_chars, WARICHU_FONT_SCALE}; use crate::shapes::{FontFeatures, TextCombineUpright, TextSpan}; -use super::flow::{minimum_oikomi_extent, FlowCell, FlowScript, FIT_TOLERANCE}; +use super::flow::{min_squeezed_extent, FlowCell, FlowScript, FIT_TOLERANCE}; use super::font_tables::{upright_baseline, vertical_metrics, vpal_table}; use super::layout::{CellKind, VerticalCell}; use super::orientation::{ @@ -50,7 +50,7 @@ pub(super) struct Fonts<'a> { /// Centered punctuation (jlreq cl-05 中点類: middle dot, full-width colon and /// semicolon), set at the centre of the em box in vertical writing. fn is_centered_punctuation(c: char) -> bool { - classify(c) == JapaneseClass::MiddleDot + classify(c) == JlreqClass::MiddleDot } /// Flow-axis shift that centres a glyph's ink band in its em body. @@ -212,7 +212,7 @@ impl<'a> SpanCells<'a> { column: 0, top: 0.0, extent, - minimum_oikomi_extent: extent, + min_squeezed_extent: extent, h_advance: self.span.font_size, ink_top: 0.0, ink_bottom: extent - self.span.letter_spacing, @@ -432,7 +432,7 @@ impl<'a> SpanCells<'a> { let start = piece_base + utf16.at(cluster_utf8); let end = piece_base + utf16.at(next_cluster_utf8); let cell = VerticalCell { - minimum_oikomi_extent: minimum_oikomi_extent(ch, extent, font_size, letter_spacing), + min_squeezed_extent: min_squeezed_extent(ch, extent, font_size, letter_spacing), h_advance, ink_top, ink_bottom, @@ -749,7 +749,7 @@ impl<'a> WarichuNote<'a> { if index > 0 { flow.shares_previous_box = true; flow.cell.extent = 0.0; - flow.cell.minimum_oikomi_extent = 0.0; + flow.cell.min_squeezed_extent = 0.0; } cells.push(flow); } diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index 162b1c5257..df571875d0 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -3,12 +3,12 @@ // (aki, oikomi, inter-script spacing) and pick column breaks (kinsoku, // burasage, oidashi). -use crate::shapes::japanese::{ +use crate::shapes::jlreq::{ aki_class, break_allowed_between, classify, pair_rule, punctuation_aki_sheds, AkiShed, - JapaneseClass, + JlreqClass, }; -use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; -use crate::shapes::text_japanese::capped_equal_allocations; +use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start}; +use crate::shapes::text_allocation::capped_equal_allocations; use crate::shapes::text_policy::TextLayoutPolicy; use crate::shapes::{LineAdjustment, TextAlign}; use unicode_segmentation::UnicodeSegmentation; @@ -98,7 +98,7 @@ impl FlowCell { fn end_slack(&self) -> f32 { let closing = self .ch - .is_some_and(|ch| classify(ch) == JapaneseClass::ClosingBracket); + .is_some_and(|ch| classify(ch) == JlreqClass::ClosingBracket); if closing { (self.cell.extent - (0.5 * self.cell.font_size + self.trailing_spacing)).max(0.0) } else { @@ -259,7 +259,7 @@ fn column_break( /// overflow: the next non-hanging item sees the overflowed cursor and wraps, /// and oidashi never pulls a hung mark since it is forbidden-at-line-start, /// not -end. -pub(super) fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> { +pub(super) fn plan_jlreq_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> { let mut placements: Vec<(usize, f32)> = Vec::with_capacity(items.len()); let mut column = 0usize; let mut cursor = 0.0f32; @@ -283,9 +283,11 @@ pub(super) fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f placements } -/// Basic non-Japanese flow. Boundaries use the entire paragraph, so font -/// and style runs cannot split a grapheme or create a word boundary. -pub(super) fn plan_policy_columns( +/// Column planning of the pairwise policies (plain, Chinese and Korean): +/// break opportunities come from `TextLayoutPolicy::break_allowed`. +/// Boundaries use the entire paragraph, so font and style runs cannot split +/// a grapheme or create a word boundary. +pub(super) fn plan_pairwise_columns( cells: &[FlowCell], text: &str, policy: TextLayoutPolicy, @@ -383,7 +385,7 @@ pub(super) fn plan_policy_columns( /// Source edge characters of every cell, including sideways runs. Offsets /// stay in UTF-16 source space; typography never inserts saved spaces. -fn policy_cell_edges(cells: &[FlowCell], text: &str) -> Vec<(Option, Option)> { +fn pairwise_cell_edges(cells: &[FlowCell], text: &str) -> Vec<(Option, Option)> { let mut offset = 0; let chars: Vec<_> = text .chars() @@ -409,8 +411,8 @@ fn policy_cell_edges(cells: &[FlowCell], text: &str) -> Vec<(Option, Optio .collect() } -pub(super) fn apply_policy_spacing(cells: &mut [FlowCell], text: &str, policy: TextLayoutPolicy) { - let edges = policy_cell_edges(cells, text); +pub(super) fn apply_pairwise_spacing(cells: &mut [FlowCell], text: &str, policy: TextLayoutPolicy) { + let edges = pairwise_cell_edges(cells, text); for i in 1..cells.len() { if cells[i].keep_with_previous || cells[i].shares_previous_box { continue; @@ -435,14 +437,14 @@ pub(super) fn apply_policy_spacing(cells: &mut [FlowCell], text: &str, policy: T }; cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression) .max(cells[i - 1].trailing_spacing.max(0.0)); - cells[i - 1].cell.minimum_oikomi_extent = cells[i - 1].cell.extent; + cells[i - 1].cell.min_squeezed_extent = cells[i - 1].cell.extent; } } /// Justify non-final columns over policy-approved gaps with bounded amounts. /// Korean word mode only expands spaces; grapheme and annotation interiors /// never become opportunities merely because a font/style run split them. -pub(super) fn policy_aligned_tops( +pub(super) fn pairwise_aligned_tops( cells: &[FlowCell], text: &str, policy: TextLayoutPolicy, @@ -451,7 +453,7 @@ pub(super) fn policy_aligned_tops( budget: f32, ) -> Vec { let mut tops: Vec = placements.iter().map(|(_, top)| *top).collect(); - let edges = policy_cell_edges(cells, text); + let edges = pairwise_cell_edges(cells, text); let graphemes: Vec = text .graphemes(true) .scan(0, |at, g| { @@ -592,7 +594,7 @@ fn inter_script_spacing(previous: &FlowCell, next: &FlowCell) -> f32 { /// between ink edges: side bearings differ across fonts, so advances alone /// would space `うpenあ` unevenly. Adjusts the preceding cell's extent and /// keeps its trailing letter-spacing. -pub(super) fn apply_inter_script_spacing(cells: &mut [FlowCell]) { +pub(super) fn apply_jlreq_inter_script_spacing(cells: &mut [FlowCell]) { for i in 1..cells.len() { let target_gap = inter_script_spacing(&cells[i - 1], &cells[i]); if target_gap <= 0.0 { @@ -608,16 +610,16 @@ pub(super) fn apply_inter_script_spacing(cells: &mut [FlowCell]) { /// JLREQ character class of each cell. Ruby bases count as simple ruby, so /// `ruby_spans[span]` overrides the cell's own class. -pub(super) fn flow_classes(cells: &[FlowCell], ruby_spans: &[bool]) -> Vec> { +pub(super) fn flow_classes(cells: &[FlowCell], ruby_spans: &[bool]) -> Vec> { cells .iter() .map(|flow| { if ruby_spans[flow.cell.span] { - return Some(JapaneseClass::SimpleRuby); + return Some(JlreqClass::SimpleRuby); } match flow.cell.kind { - CellKind::TateChuYoko { .. } => Some(JapaneseClass::TateChuYoko), - CellKind::Rotated { .. } => Some(JapaneseClass::Western), + CellKind::TateChuYoko { .. } => Some(JlreqClass::TateChuYoko), + CellKind::Rotated { .. } => Some(JlreqClass::Western), _ => flow.ch.map(classify), } }) @@ -628,7 +630,7 @@ pub(super) fn flow_classes(cells: &[FlowCell], ruby_spans: &[bool]) -> Vec]) { +pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], aki_classes: &[Option]) { for (index, shed) in punctuation_aki_sheds(aki_classes) { cells[index].shed_to_half_em(shed == AkiShed::Leading); } @@ -640,9 +642,9 @@ pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], aki_classes: &[Option /// `flow_classes` class. pub(super) fn aki_classes( cells: &[FlowCell], - classes: &[Option], + classes: &[Option], proportional_spans: &[bool], -) -> Vec> { +) -> Vec> { cells .iter() .zip(classes) @@ -658,7 +660,7 @@ pub(super) fn aki_classes( /// Smallest legal flow extent after oikomi. Full-width punctuation floors at /// a half-em frame, or at its shaped extent when `vpal` has already removed /// the aki. -pub(super) fn minimum_oikomi_extent( +pub(super) fn min_squeezed_extent( ch: Option, extent: f32, font_size: f32, @@ -667,11 +669,11 @@ pub(super) fn minimum_oikomi_extent( let half_em_frame = ch.is_some_and(|ch| { matches!( classify(ch), - JapaneseClass::OpeningBracket - | JapaneseClass::ClosingBracket - | JapaneseClass::FullStop - | JapaneseClass::Comma - | JapaneseClass::MiddleDot + JlreqClass::OpeningBracket + | JlreqClass::ClosingBracket + | JlreqClass::FullStop + | JlreqClass::Comma + | JlreqClass::MiddleDot ) }); if half_em_frame { @@ -690,8 +692,8 @@ fn boundary_em(cells: &[FlowCell], boundary: usize) -> f32 { .min(cells[boundary + 1].cell.font_size) } -fn explicit_pair_spacing_em(before: JapaneseClass, after: JapaneseClass) -> f32 { - if matches!(before, JapaneseClass::DividingPunctuation) { +fn explicit_pair_spacing_em(before: JlreqClass, after: JlreqClass) -> f32 { + if matches!(before, JlreqClass::DividingPunctuation) { pair_rule(before, after).preferred_em } else { 0.0 @@ -700,14 +702,14 @@ fn explicit_pair_spacing_em(before: JapaneseClass, after: JapaneseClass) -> f32 /// Adjacent classes at `boundary`, when both cells have one. fn boundary_classes( - classes: &[Option], + classes: &[Option], boundary: usize, -) -> Option<(JapaneseClass, JapaneseClass)> { +) -> Option<(JlreqClass, JlreqClass)> { Some((classes[boundary]?, classes[boundary + 1]?)) } /// Preferred pair spacing (in em) at every boundary, per the JLREQ table. -pub(super) fn preferred_pair_spacing(classes: &[Option]) -> Vec { +pub(super) fn preferred_pair_spacing(classes: &[Option]) -> Vec { (0..classes.len().saturating_sub(1)) .map(|boundary| { boundary_classes(classes, boundary) @@ -721,7 +723,7 @@ pub(super) fn preferred_pair_spacing(classes: &[Option]) -> Vec], + classes: &[Option], ) { for boundary in 0..cells.len().saturating_sub(1) { let Some((before, after)) = boundary_classes(classes, boundary) else { @@ -736,13 +738,10 @@ pub(super) fn materialize_explicit_pair_spacing( /// Cell holding the removable spacing of a boundary, and whether that space /// leads the cell (its glyph then moves up with the reduction). -fn spacing_owner(before: JapaneseClass, after: JapaneseClass, boundary: usize) -> (usize, bool) { +fn spacing_owner(before: JlreqClass, after: JlreqClass, boundary: usize) -> (usize, bool) { if explicit_pair_spacing_em(before, after) > 0.0 { (boundary, false) - } else if matches!( - after, - JapaneseClass::OpeningBracket | JapaneseClass::MiddleDot - ) { + } else if matches!(after, JlreqClass::OpeningBracket | JlreqClass::MiddleDot) { // After sequence shedding, the remaining aki is the next glyph's // embedded leading space. (boundary + 1, true) @@ -755,7 +754,7 @@ fn spacing_owner(before: JapaneseClass, after: JapaneseClass, boundary: usize) - /// below its oikomi floor. Returns the amount removed. fn reduce_cell_spacing(cells: &mut [FlowCell], index: usize, amount: f32, leading: bool) -> f32 { let cell = &mut cells[index].cell; - let removable = (cell.extent - cell.minimum_oikomi_extent).max(0.0); + let removable = (cell.extent - cell.min_squeezed_extent).max(0.0); let amount = amount.min(cell.extent.max(0.0)).min(removable); cell.extent -= amount; if leading { @@ -768,7 +767,7 @@ fn reduce_cell_spacing(cells: &mut [FlowCell], index: usize, amount: f32, leadin /// column bottom). Returns true when it removed any. fn discard_explicit_spacing_at_column_edges( cells: &mut [FlowCell], - classes: &[Option], + classes: &[Option], pair_spacing_em: &mut [f32], placements: &[(usize, f32)], ) -> bool { @@ -811,16 +810,16 @@ fn break_needs_adjustment(cells: &[FlowCell], i: usize, cursor: f32, max_height: /// JLREQ oikomi: before wrapping a non-hanging item whose break needs line /// adjustment, try to keep it in the column by reducing legal aki in table /// priority order. When the whole deficit cannot be recovered, leave the -/// line to the oidashi/kinsoku planner. `policy` decides whether squeezing +/// line to the oidashi/kinsoku planner. `adjustment` decides whether squeezing /// comes first, only when pushing out would break kinsoku, or never. pub(super) fn apply_ordered_oikomi( cells: &mut [FlowCell], - classes: &[Option], + classes: &[Option], pair_spacing_em: &mut [f32], max_height: f32, - policy: LineAdjustment, + adjustment: LineAdjustment, ) { - if !is_bounded(max_height) || policy == LineAdjustment::PushOutOnly { + if !is_bounded(max_height) || adjustment == LineAdjustment::PushOutOnly { return; } let mut column_start = 0usize; @@ -830,7 +829,7 @@ pub(super) fn apply_ordered_oikomi( if overflows(cursor, &item, max_height) { let needed = item.extent - item.end_slack; let squeeze = break_needs_adjustment(cells, i, cursor, max_height) - && (policy == LineAdjustment::PushInFirst + && (adjustment == LineAdjustment::PushInFirst || !push_out_resolves(cells, column_start, i, max_height)); if squeeze { let deficit = cursor + needed - max_height; @@ -862,7 +861,7 @@ fn push_out_resolves(cells: &[FlowCell], column_start: usize, i: usize, max_heig /// Returns the extent removed from the line. fn compress_line( cells: &mut [FlowCell], - classes: &[Option], + classes: &[Option], pair_spacing_em: &mut [f32], line: std::ops::Range, deficit: f32, @@ -882,7 +881,7 @@ fn compress_line( let (owner, _) = spacing_owner(before, after, boundary); let owner = &cells[owner].cell; let rule_capacity = (pair_spacing_em[boundary] - rule.minimum_em) * em; - let physical_capacity = (owner.extent - owner.minimum_oikomi_extent).max(0.0); + let physical_capacity = (owner.extent - owner.min_squeezed_extent).max(0.0); let capacity = rule_capacity.min(physical_capacity); if capacity > EPSILON { opportunities.push((boundary, rule.shrink_priority, capacity)); @@ -935,16 +934,16 @@ fn compress_line( /// re-plan is undone. pub(super) fn plan_with_edge_trimming( cells: &mut [FlowCell], - classes: &[Option], + classes: &[Option], pair_spacing_em: &mut [f32], max_height: f32, ) -> Vec<(usize, f32)> { - let mut placements = plan_columns(&flow_items(cells), max_height); + let mut placements = plan_jlreq_columns(&flow_items(cells), max_height); for _ in 0..cells.len() { if !discard_explicit_spacing_at_column_edges(cells, classes, pair_spacing_em, &placements) { break; } - placements = plan_columns(&flow_items(cells), max_height); + placements = plan_jlreq_columns(&flow_items(cells), max_height); } // Cells whose trim moved them off the edge keep their aki for good, so // the loop cannot oscillate. @@ -959,7 +958,7 @@ pub(super) fn plan_with_edge_trimming( } let opening = flow .ch - .is_some_and(|ch| classify(ch) == JapaneseClass::OpeningBracket); + .is_some_and(|ch| classify(ch) == JlreqClass::OpeningBracket); if at_column_head(&placements, index) && opening && flow.shed_to_half_em(true) { trimmed.push((index, true)); } @@ -973,7 +972,7 @@ pub(super) fn plan_with_edge_trimming( if trimmed.is_empty() { break; } - let replanned = plan_columns(&flow_items(cells), max_height); + let replanned = plan_jlreq_columns(&flow_items(cells), max_height); let mut moved = false; for (index, head) in trimmed { let still_at_edge = if head { @@ -988,7 +987,7 @@ pub(super) fn plan_with_edge_trimming( } } placements = if moved { - plan_columns(&flow_items(cells), max_height) + plan_jlreq_columns(&flow_items(cells), max_height) } else { replanned }; @@ -1012,7 +1011,7 @@ fn at_column_end(placements: &[(usize, f32)], index: usize) -> bool { /// Skips the last column. pub(super) fn ordered_expansion_offsets( cells: &[FlowCell], - classes: &[Option], + classes: &[Option], pair_spacing_em: &[f32], placements: &[(usize, f32)], column_used: &[f32], @@ -1102,7 +1101,7 @@ mod tests { #[test] fn plan_columns_keeps_upright_latin_words_whole() { let items: Vec = "あいPenpot".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 70.0); + let placements = plan_jlreq_columns(&items, 70.0); assert_eq!(columns(&placements), vec![0, 0, 1, 1, 1, 1, 1, 1]); } @@ -1113,14 +1112,14 @@ mod tests { run_item(44.0, false), run_item(38.0, true), ]; - let placements = plan_columns(&items, 90.0); + let placements = plan_jlreq_columns(&items, 90.0); assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 44.0)]); } #[test] fn plan_columns_keeps_full_width_numbers_whole() { let items: Vec = "あいう2026年".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 40.0); + let placements = plan_jlreq_columns(&items, 40.0); assert_eq!(columns(&placements), vec![0, 0, 0, 1, 1, 1, 1, 2]); } @@ -1151,21 +1150,21 @@ mod tests { #[test] fn plan_columns_keeps_a_numeral_with_its_unit() { let items: Vec = "体重60㎏".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 40.0); + let placements = plan_jlreq_columns(&items, 40.0); assert_eq!(columns(&placements), vec![0, 0, 1, 1, 1]); } #[test] fn plan_columns_never_leaves_an_empty_column() { let items: Vec = "「「あ".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 25.0); + let placements = plan_jlreq_columns(&items, 25.0); assert_eq!(columns(&placements), vec![0, 0, 1]); } #[test] fn plan_columns_oidashi_ignores_float_noise_in_the_budget() { let items = vec![item(22.0, 'あ'), item(22.0, 'い'), item(22.0, '」')]; - let placements = plan_columns(&items, 44.0 - 0.00002); + let placements = plan_jlreq_columns(&items, 44.0 - 0.00002); assert_eq!(columns(&placements), vec![0, 1, 1]); } @@ -1208,7 +1207,7 @@ mod tests { #[test] fn plan_columns_breaks_on_overflow() { let items: Vec = "あいうえお".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 25.0); + let placements = plan_jlreq_columns(&items, 25.0); assert_eq!( placements, vec![(0, 0.0), (0, 10.0), (1, 0.0), (1, 10.0), (2, 0.0)] @@ -1218,14 +1217,14 @@ mod tests { #[test] fn plan_columns_ignores_float_noise_in_the_budget() { let items: Vec = "あいう".chars().map(|c| item(22.0, c)).collect(); - let placements = plan_columns(&items, 66.0 - 0.00002); + let placements = plan_jlreq_columns(&items, 66.0 - 0.00002); assert!(placements.iter().all(|(column, _)| *column == 0)); } #[test] fn plan_columns_oversized_item_gets_own_column() { let items = vec![item(10.0, 'あ'), item(100.0, 'い'), item(10.0, 'う')]; - let placements = plan_columns(&items, 25.0); + let placements = plan_jlreq_columns(&items, 25.0); assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (2, 0.0)]); } @@ -1239,7 +1238,7 @@ mod tests { item(10.0, '。'), item(10.0, 'う'), ]; - let placements = plan_columns(&items, 25.0); + let placements = plan_jlreq_columns(&items, 25.0); assert_eq!(placements, vec![(0, 0.0), (0, 10.0), (0, 20.0), (1, 0.0)]); assert_eq!(placements[2].0, 0, "。 hangs in the current column"); } @@ -1253,7 +1252,7 @@ mod tests { item(10.0, 'い'), item(10.0, 'う'), ]; - let placements = plan_columns(&items, 25.0); + let placements = plan_jlreq_columns(&items, 25.0); assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 10.0), (2, 0.0)]); } @@ -1262,7 +1261,7 @@ mod tests { // A column full of open brackets cannot be emptied: oidashi moves at // most four of them along with the overflowing あ. let items: Vec = "「「「「「「あ".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 60.0); + let placements = plan_jlreq_columns(&items, 60.0); let first_column_count = placements.iter().filter(|(c, _)| *c == 0).count(); assert_eq!(first_column_count, 2); } @@ -1277,7 +1276,7 @@ mod tests { item(10.0, '「'), item(10.0, 'い'), ]; - let placements = plan_columns(&items, 20.0); + let placements = plan_jlreq_columns(&items, 20.0); let mut column_used = std::collections::BTreeMap::new(); for (it, (column, top)) in items.iter().zip(&placements) { let used: &mut f32 = column_used.entry(*column).or_default(); @@ -1297,7 +1296,7 @@ mod tests { .chars() .map(|c| item(10.0, c)) .collect(); - let placements = plan_columns(&items, 50.0); + let placements = plan_jlreq_columns(&items, 50.0); assert_eq!(columns(&placements), vec![0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]); } diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 1599a78c41..96e816714b 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -9,11 +9,11 @@ use skia_safe::{ use crate::globals::get_resources; use crate::math::Rect; use crate::render::FallbackFonts; -use crate::shapes::japanese::JapaneseClass; +use crate::shapes::jlreq::JlreqClass; use crate::shapes::text_policy::TextLayoutPolicy; use crate::shapes::{ - merge_fills, AppliedTextTransform, FontFeatures, GrowType, LineAdjustment, Paragraph, RubySide, - TextAlign, TextContent, TextSpan, VerticalAlign, + merge_fills, AnnotationSide, AppliedTextTransform, FontFeatures, GrowType, LineAdjustment, + Paragraph, TextAlign, TextContent, TextSpan, VerticalAlign, }; use crate::utils::get_fallback_fonts; @@ -24,10 +24,10 @@ use super::annotations::{ }; use super::cells::{Fonts, SpanCells, WarichuNote}; use super::flow::{ - aki_classes, align_offset_along_column, apply_inter_script_spacing, apply_ordered_oikomi, - apply_policy_spacing, flow_classes, is_bounded, materialize_explicit_pair_spacing, - ordered_expansion_offsets, plan_policy_columns, plan_with_edge_trimming, policy_aligned_tops, - preferred_pair_spacing, shed_punctuation_aki, FlowCell, FIT_TOLERANCE, + aki_classes, align_offset_along_column, apply_jlreq_inter_script_spacing, apply_ordered_oikomi, + apply_pairwise_spacing, flow_classes, is_bounded, materialize_explicit_pair_spacing, + ordered_expansion_offsets, pairwise_aligned_tops, plan_pairwise_columns, + plan_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, FlowCell, FIT_TOLERANCE, }; use super::paint::LayoutDraws; use super::shaping::ShapedRun; @@ -89,7 +89,7 @@ pub struct VerticalCell { /// Flow-axis advance, from `vmtx` when present, else the horizontal one. pub extent: f32, /// Oikomi lower bound: the glyph frame before removable pair spacing. - pub minimum_oikomi_extent: f32, + pub min_squeezed_extent: f32, /// Shaped horizontal advance, used to centre the glyph in the column. pub h_advance: f32, /// Visible glyph-ink edges along the flow axis, relative to `top`. @@ -100,7 +100,7 @@ pub struct VerticalCell { pub font_size: f32, /// Span text decoration, painted as vertical bars along the column. pub decoration: Option, - pub underline_side: RubySide, + pub underline_side: AnnotationSide, /// Draw-only flow shift for half-width opening punctuation (jlreq aki). pub glyph_flow_shift: f32, } @@ -272,7 +272,7 @@ impl ColumnGeometry { && !span.text_emphasis.is_none() && span.emphasis_side(true) == side }) - .map(|span| span.font_size * crate::shapes::text_japanese::EMPHASIS_FONT_SCALE) + .map(|span| span.font_size * crate::shapes::text_attrs::EMPHASIS_FONT_SCALE) .fold(0.0, f32::max); let decoration = spans .iter() @@ -294,8 +294,8 @@ impl ColumnGeometry { }; Self { base_width: max_font_size * line_height, - under_gutter: gutter(RubySide::Under), - over_gutter: gutter(RubySide::Over), + under_gutter: gutter(AnnotationSide::Under), + over_gutter: gutter(AnnotationSide::Over), } } @@ -490,9 +490,9 @@ fn keep_ruby_bases_together(flow: &mut [FlowCell], paragraph: &Paragraph) { /// Final flow-axis top of each cell: its planned offset plus the text-align /// shift. Justify stretches every column but the last to fill a bounded wrap /// height. -fn aligned_tops( +fn jlreq_aligned_tops( flow: &[FlowCell], - classes: &[Option], + classes: &[Option], pair_spacing_em: &[f32], placements: &[(usize, f32)], align: TextAlign, @@ -531,7 +531,7 @@ struct PlannedParagraph { text: String, flow: Vec, ruby_units: Vec, - classes: Vec>, + classes: Vec>, pair_spacing_em: Vec, /// (column, offset from the column top) of each flow cell. placements: Vec<(usize, f32)>, @@ -576,8 +576,8 @@ impl PlannedParagraph { .iter() .map(|t| t.text.as_str()) .collect(); - apply_policy_spacing(&mut flow, &text, policy); - let placements = plan_policy_columns(&flow, &text, policy, max_height); + apply_pairwise_spacing(&mut flow, &text, policy); + let placements = plan_pairwise_columns(&flow, &text, policy, max_height); return Self { policy, text, @@ -589,7 +589,7 @@ impl PlannedParagraph { }; } - apply_inter_script_spacing(&mut flow); + apply_jlreq_inter_script_spacing(&mut flow); let classes = flow_classes(&flow, &ruby_spans); let aki = aki_classes(&flow, &classes, &vpal_spans); shed_punctuation_aki(&mut flow, &aki); @@ -648,7 +648,7 @@ impl PlannedParagraph { align }; let tops = if self.policy == TextLayoutPolicy::Japanese && align != TextAlign::Distribute { - aligned_tops( + jlreq_aligned_tops( &self.flow, &self.classes, &self.pair_spacing_em, @@ -657,7 +657,7 @@ impl PlannedParagraph { max_height, ) } else { - policy_aligned_tops( + pairwise_aligned_tops( &self.flow, &self.text, self.policy, @@ -677,7 +677,7 @@ impl PlannedParagraph { if let Some(piece) = cells.last() { cell.top = piece.top; cell.extent = piece.extent; - cell.minimum_oikomi_extent = piece.minimum_oikomi_extent; + cell.min_squeezed_extent = piece.min_squeezed_extent; cell.ink_top = piece.ink_top; cell.ink_bottom = piece.ink_bottom; } @@ -1043,7 +1043,7 @@ mod tests { assert!(layout .cells .iter() - .all(|cell| cell.minimum_oikomi_extent <= cell.extent)); + .all(|cell| cell.min_squeezed_extent <= cell.extent)); } #[test] diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index deeaa54b7d..39357c8259 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -721,7 +721,7 @@ fn decoration_bar( let bar_x = if line_through { x_center } else { - let sign = if cell.underline_side == crate::shapes::RubySide::Over { + let sign = if cell.underline_side == crate::shapes::AnnotationSide::Over { 1.0 } else { -1.0 diff --git a/render-wasm/src/shapes/text_warichu.rs b/render-wasm/src/shapes/text_warichu.rs new file mode 100644 index 0000000000..e164a5688f --- /dev/null +++ b/render-wasm/src/shapes/text_warichu.rs @@ -0,0 +1,53 @@ +//! Warichu (割注): inline notes set in two half-size sub-lines. + +use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start}; + +pub const WARICHU_FONT_SCALE: f32 = 0.5; + +/// Char index where a warichu run splits into its two sub-lines: the +/// midpoint (first line longer), moved to the nearest split that keeps +/// kinsoku. At equal distance a forward move wins, pulling the mark up into +/// the first sub-line (jlreq). Falls back to the midpoint. +pub(crate) fn warichu_split_chars(text: &str) -> usize { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mid = n.div_ceil(2); + let valid = |split: usize| { + split >= 1 + && split < n + && !forbidden_at_line_start(chars[split]) + && !forbidden_at_line_end(chars[split - 1]) + }; + if valid(mid) { + return mid; + } + for distance in 1..n { + if valid(mid + distance) { + return mid + distance; + } + if mid > distance && valid(mid - distance) { + return mid - distance; + } + } + mid +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warichu_split_balances_and_respects_kinsoku() { + // Balanced midpoint when nothing forbids it, first line longer. + assert_eq!(warichu_split_chars("あいうえおか"), 3); + assert_eq!(warichu_split_chars("あいうえお"), 3); + // A comma at the midpoint may end the first sub-line... + assert_eq!(warichu_split_chars("あい、うえ"), 3); + // ...but must not start the second one: the split moves forward. + assert_eq!(warichu_split_chars("あいう、えお"), 4); + // An opening bracket must not end the first sub-line. + assert_eq!(warichu_split_chars("あい「うえお"), 4); + // Pathological all-forbidden text keeps the midpoint. + assert_eq!(warichu_split_chars("、、、、"), 2); + } +} diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 9a90c3866a..0ae5d3fe2f 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -12,9 +12,9 @@ use crate::{with_current_shape, with_current_shape_mut, with_state}; use crate::error::Error; pub mod helpers; -mod japanese_layout; +mod layout_attrs; -pub use japanese_layout::*; +pub use layout_attrs::*; const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); @@ -151,7 +151,8 @@ pub struct RawTextSpan { ruby_size: u8, ruby_align: u8, ruby_overhang: u8, - ruby_side: u8, + /// Bit 0: over/under side of normal ruby. Bit 1: Zhuyin. + ruby_flags: u8, text_emphasis_position: u8, text_underline_position: u8, font_size: f32, @@ -229,8 +230,8 @@ impl From for shapes::TextSpan { span.ruby_overhang = RawRubyOverhang::from(value.ruby_overhang).into(); // Bit 1 selects Zhuyin; bit 0 retains the existing over/under side. // Reject unknown flags instead of treating corrupt bytes as Zhuyin. - let ruby_flags = if value.ruby_side <= 3 { - value.ruby_side + let ruby_flags = if value.ruby_flags <= 3 { + value.ruby_flags } else { 0 }; @@ -526,7 +527,7 @@ pub extern "C" fn calculate_position_data() -> *mut u8 { mod tests { use super::*; use crate::shapes::{ - AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize, + AnnotationClearance, AnnotationSide, FontFeatures, RubyAlign, RubyOverhang, RubySize, }; /// The shared CLJS writer writes PARAGRAPH-ATTR-U8-SIZE (20) @@ -536,11 +537,11 @@ mod tests { #[test] fn zhuyin_flags_keep_legacy_side_and_header_size() { for (flags, zhuyin, side) in [ - (0, false, RubySide::Over), - (1, false, RubySide::Under), - (2, true, RubySide::Over), - (3, true, RubySide::Under), - (255, false, RubySide::Over), + (0, false, AnnotationSide::Over), + (1, false, AnnotationSide::Under), + (2, true, AnnotationSide::Over), + (3, true, AnnotationSide::Under), + (255, false, AnnotationSide::Over), ] { let mut bytes = [0u8; std::mem::size_of::()]; bytes[13] = flags; @@ -683,7 +684,7 @@ mod tests { assert_eq!(span.ruby_size, RubySize::Quarter); assert_eq!(span.ruby_align, RubyAlign::SpaceBetween); assert_eq!(span.ruby_overhang, RubyOverhang::None); - assert_eq!(span.ruby_side, RubySide::Under); + assert_eq!(span.ruby_side, AnnotationSide::Under); } #[test] @@ -702,7 +703,7 @@ mod tests { assert_eq!(span.ruby_size, RubySize::Half); assert_eq!(span.ruby_align, RubyAlign::SpaceAround); assert_eq!(span.ruby_overhang, RubyOverhang::Auto); - assert_eq!(span.ruby_side, RubySide::Over); + assert_eq!(span.ruby_side, AnnotationSide::Over); } #[test] diff --git a/render-wasm/src/wasm/text/japanese_layout.rs b/render-wasm/src/wasm/text/layout_attrs.rs similarity index 96% rename from render-wasm/src/wasm/text/japanese_layout.rs rename to render-wasm/src/wasm/text/layout_attrs.rs index f8f467c1b8..65027208fe 100644 --- a/render-wasm/src/wasm/text/japanese_layout.rs +++ b/render-wasm/src/wasm/text/layout_attrs.rs @@ -1,6 +1,6 @@ use crate::shapes::{ - self, AnnotationClearance, FontFeatures, LineAdjustment, RubyAlign, RubyOverhang, RubySide, - RubySize, TextCombineUpright, TextEmphasis, + self, AnnotationClearance, AnnotationSide, FontFeatures, LineAdjustment, RubyAlign, + RubyOverhang, RubySize, TextCombineUpright, TextEmphasis, }; use macros::ToJs; @@ -308,11 +308,11 @@ pub enum RawRubySide { raw_enum_from_byte!(RawRubySide, Over, [Over, Under]); -impl From for RubySide { +impl From for AnnotationSide { fn from(value: RawRubySide) -> Self { match value { - RawRubySide::Over => RubySide::Over, - RawRubySide::Under => RubySide::Under, + RawRubySide::Over => AnnotationSide::Over, + RawRubySide::Under => AnnotationSide::Under, } } }