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