diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index c17a9c92ef..e8d35bad4e 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -2,6 +2,7 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr}; use std::collections::{BTreeSet, HashMap, HashSet}; use crate::error::{Error, Result}; +use crate::shapes::cjk_chars::{is_hangul, is_kana}; use crate::shapes::text_policy::LanguageTag; use crate::shapes::{FontFamily, FontStyle}; use crate::uuid::Uuid; @@ -71,14 +72,6 @@ impl CjkFace { } } -fn is_kana(c: char) -> bool { - matches!(c, '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{FF66}'..='\u{FF9F}') -} - -fn is_hangul(c: char) -> bool { - matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}') -} - /// Families that take part in character fallback, with the CJK region of /// those that draw Han. #[derive(Debug, Default)] diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index fd11d5b892..e87ab532f7 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -575,6 +575,34 @@ pub fn try_paint_from_layout_cache( Ok(false) } +/// Paint the warichu, emphasis marks and ruby of the laid-out horizontal +/// paragraph `index` at `origin`, with the paint of this pass. +fn paint_horizontal_annotations( + canvas: &Canvas, + text_content: &TextContent, + index: usize, + laid_out: &LayoutParagraph, + origin: (f32, f32), +) { + paint_horizontal_notes_and_marks(canvas, text_content, index, laid_out, origin); + crate::shapes::paint_horizontal_ruby(canvas, text_content, index, laid_out, origin.0, origin.1); +} + +/// The warichu and emphasis marks of `paint_horizontal_annotations`. +fn paint_horizontal_notes_and_marks( + canvas: &Canvas, + text_content: &TextContent, + index: usize, + laid_out: &LayoutParagraph, + (x, y): (f32, f32), +) { + let plans = text_content.horizontal_plans(); + if let (Some(paragraph), Some(plan)) = (text_content.paragraphs().get(index), plans.get(index)) + { + crate::shapes::paint_horizontal_warichu(canvas, paragraph, plan, laid_out, x, y); + crate::shapes::paint_horizontal_emphasis(canvas, paragraph, plan, laid_out, x, y); + } +} fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) { let selrect = shape.selrect(); // Absolute image/gradient shaders were baked at `layout_paint_origin`. Paint @@ -612,33 +640,10 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC .zip(text_content.paragraphs().get(index)) .map_or(0.0, |(plan, model)| plan.x_offset(model, selrect.width())); paragraph.paint(canvas, (x, y_accum)); + paint_horizontal_annotations(canvas, text_content, index, paragraph, (x, y_accum)); if let (Some(text_paragraph), Some(plan)) = (text_content.paragraphs().get(index), plans.get(index)) { - crate::shapes::paint_horizontal_warichu( - canvas, - text_paragraph, - plan, - paragraph, - x, - y_accum, - ); - crate::shapes::paint_horizontal_emphasis( - canvas, - text_paragraph, - plan, - paragraph, - x, - y_accum, - ); - crate::shapes::paint_horizontal_ruby( - canvas, - text_content, - index, - paragraph, - x, - y_accum, - ); if draw_decorations { for deco in decoration_segments(paragraph, text_paragraph, &plan.offsets, x, y_accum) @@ -791,45 +796,23 @@ fn paint_text_with_emoji_overlay( let mut layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); - let plans = text_content.horizontal_plans(); for para in &mut layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); - - if let (Some(source_paragraph), Some(plan)) = ( - text_content.paragraphs().get(para.source_paragraph), - plans.get(para.source_paragraph), - ) { - crate::shapes::paint_horizontal_warichu( - canvas, - source_paragraph, - plan, - ¶.paragraph, - para.x, - para.y, - ); - crate::shapes::paint_horizontal_emphasis( - canvas, - source_paragraph, - plan, - ¶.paragraph, - para.x, - para.y, - ); - } - + let (index, origin) = (para.source_paragraph, (para.x, para.y)); + paint_horizontal_notes_and_marks(canvas, text_content, index, ¶.paragraph, origin); + // The emoji overlay goes over the marks and under the ruby, which + // takes the paint of this pass like warichu and emphasis. if overlay_emoji { paint_emoji_overlay(canvas, para); } - - // Like warichu and emphasis, ruby takes the paint of this pass. crate::shapes::paint_horizontal_ruby( canvas, text_content, - para.source_paragraph, + index, ¶.paragraph, - para.x, - para.y, + origin.0, + origin.1, ); for deco in ¶.decorations { diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index bcbb2247cc..c814b86f23 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -11,6 +11,7 @@ use std::iter::once; mod blend; mod blurs; mod bools; +pub mod cjk_chars; mod corners; mod fills; mod fonts; diff --git a/render-wasm/src/shapes/cjk_chars.rs b/render-wasm/src/shapes/cjk_chars.rs new file mode 100644 index 0000000000..3d53551d1f --- /dev/null +++ b/render-wasm/src/shapes/cjk_chars.rs @@ -0,0 +1,64 @@ +//! Character ranges and punctuation sets of CJK text, shared by the layout +//! policies, the JLREQ classes, Zhuyin and font fallback. Where two users +//! need different ranges on purpose, each keeps its own function here with +//! a note on why. The JLREQ class tables (`jlreq`) and the vertical upright +//! table (`text_shaping::is_upright_char`) follow their own references and +//! stay with their users. + +/// Han ideographs of the BMP unified blocks: Extension A and the URO. +pub fn is_bmp_unified_han(c: char) -> bool { + matches!(u32::from(c), 0x3400..=0x4DBF | 0x4E00..=0x9FFF) +} + +/// Han ideographs for the Chinese and Korean presets: the BMP unified +/// blocks, the compatibility ideographs and Extensions B to H. +pub fn is_han(c: char) -> bool { + is_bmp_unified_han(c) || matches!(u32::from(c), 0xF900..=0xFAFF | 0x20000..=0x323AF) +} + +/// A base a Zhuyin reading may annotate. Besides `is_han`, it takes the +/// Yijing hexagram symbols (U+4DC0..U+4DFF) between Extension A and the URO, +/// which the Zhuyin check has always accepted, so saved readings keep their +/// mode. +pub fn is_zhuyin_base(c: char) -> bool { + is_han(c) || matches!(u32::from(c), 0x4DC0..=0x4DFF) +} + +/// Assigned hiragana, including the voiced marks and the iteration marks. +pub fn is_hiragana(c: char) -> bool { + matches!(u32::from(c), 0x3041..=0x309F) +} + +/// Katakana, its phonetic extensions and half-width katakana. +pub fn is_katakana(c: char) -> bool { + matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F) +} + +/// Kana that mark untagged text as Japanese for font choice: the whole +/// Hiragana block (with its unassigned U+3040) and `is_katakana`. +pub fn is_kana(c: char) -> bool { + u32::from(c) == 0x3040 || is_hiragana(c) || is_katakana(c) +} + +/// Hangul syllables, conjoining jamo and compatibility jamo. +pub fn is_hangul(c: char) -> bool { + matches!(u32::from(c), 0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF) +} + +/// Full-width opening brackets with a removable leading half-em. +pub const FULLWIDTH_OPENING_BRACKETS: &str = "([{〈《「『【〔〖〘〚"; + +/// Full-width closing brackets with a removable trailing half-em. +pub const FULLWIDTH_CLOSING_BRACKETS: &str = ")]}〉》」』】〕〗〙〛"; + +/// Full-width commas and full stops. +pub const COMMAS_AND_FULL_STOPS: &str = ",。、."; + +/// CJK and ASCII punctuation, brackets and quotes. A comma or full stop next +/// to one of these does not hang alone. +pub fn is_punctuation(c: char) -> bool { + COMMAS_AND_FULL_STOPS.contains(c) + || FULLWIDTH_OPENING_BRACKETS.contains(c) + || FULLWIDTH_CLOSING_BRACKETS.contains(c) + || "!?:;…‥—―‘’“”.,;!?:()[]{}".contains(c) +} diff --git a/render-wasm/src/shapes/jlreq.rs b/render-wasm/src/shapes/jlreq.rs index 5feba8e280..9578a0767d 100644 --- a/render-wasm/src/shapes/jlreq.rs +++ b/render-wasm/src/shapes/jlreq.rs @@ -6,6 +6,8 @@ //! characters, and callers assign the virtual classes when building //! reference marks, ruby, grouped numerals, warichu, or tate-chu-yoko. +use super::cjk_chars::{is_bmp_unified_han, is_hiragana, is_katakana}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] pub enum JlreqClass { @@ -386,6 +388,8 @@ pub const fn pair_rule(before: JlreqClass, after: JlreqClass) -> PairRule { PAIR_RULES[before.index()][after.index()] } +// JLREQ Appendix A class tables. They are wider than the `cjk_chars` +// punctuation sets the pairwise policies use. const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“〝«⦅⦅「"; const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”〟〞»⦆⦆」"; const HYPHENS: &str = "‐゠–〜~"; @@ -408,7 +412,7 @@ const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏"; pub fn classify(c: char) -> JlreqClass { // Common letters skip the table scans. match c { - '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' => JlreqClass::Ideographic, + c if is_bmp_unified_han(c) => JlreqClass::Ideographic, 'a'..='z' | 'A'..='Z' => JlreqClass::Western, _ => classify_by_tables(c), } @@ -517,14 +521,8 @@ pub fn is_cjk_text_char(c: char) -> bool { || matches!(u32::from(c), 0x3000..=0x30FF | 0x31F0..=0x31FF | 0xFF00..=0xFFEF) } -fn is_hiragana(c: char) -> bool { - matches!(u32::from(c), 0x3041..=0x309F) -} - -fn is_katakana(c: char) -> bool { - matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F) -} - +/// JLREQ cl-19 ideographs. Wider than `cjk_chars::is_han` (radicals, strokes, +/// the compatibility supplement and a few marks), as JLREQ Appendix A lists. fn is_ideographic(c: char) -> bool { matches!(u32::from(c), 0x2E80..=0x2FDF diff --git a/render-wasm/src/shapes/line_breaks.rs b/render-wasm/src/shapes/line_breaks.rs index 79a1f5b005..16211ce3be 100644 --- a/render-wasm/src/shapes/line_breaks.rs +++ b/render-wasm/src/shapes/line_breaks.rs @@ -42,6 +42,70 @@ use super::jlreq::{classify, pair_rule, JlreqClass}; use super::text_policy::TextLayoutPolicy; use unicode_segmentation::UnicodeSegmentation; +/// One grapheme of a paragraph's text with the policy's rules for the +/// boundary before it. The first grapheme allows both kinds of break. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PolicyBoundary { + /// UTF-16 offset of the grapheme in the text. + pub utf16: usize, + /// First character of the grapheme, which the policy rules read. + pub first: char, + /// `TextLayoutPolicy::break_allowed` before this grapheme. + pub allowed: bool, + /// `TextLayoutPolicy::emergency_break_allowed` before this grapheme. + pub emergency: bool, +} + +/// Break boundaries of a whole paragraph text (all spans, transformed), so +/// font and style runs cannot split a grapheme or create a boundary. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PolicyBoundaries { + pub graphemes: Vec, + /// UTF-16 length of the text. + pub length: usize, +} + +impl PolicyBoundaries { + pub fn new(text: &str, policy: TextLayoutPolicy) -> Self { + let mut length = 0; + let mut previous: Option = None; + let graphemes = text + .graphemes(true) + .map(|grapheme| { + let first = grapheme.chars().next().unwrap(); + let boundary = PolicyBoundary { + utf16: length, + first, + allowed: previous.is_none_or(|before| policy.break_allowed(before, first)), + emergency: previous + .is_none_or(|before| policy.emergency_break_allowed(before, first)), + }; + previous = Some(first); + length += grapheme.encode_utf16().count(); + boundary + }) + .collect(); + Self { graphemes, length } + } + + /// (allowed, emergency) at a UTF-16 offset: a grapheme start, or the + /// text end, which allows both. `None` inside a grapheme. + pub fn at(&self, utf16: usize) -> Option<(bool, bool)> { + if utf16 == self.length { + return Some((true, true)); + } + self.graphemes + .binary_search_by_key(&utf16, |g| g.utf16) + .ok() + .map(|index| { + ( + self.graphemes[index].allowed, + self.graphemes[index].emergency, + ) + }) + } +} + /// 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)] diff --git a/render-wasm/src/shapes/text_allocation.rs b/render-wasm/src/shapes/text_allocation.rs index c859de7ebf..454ce8d9b5 100644 --- a/render-wasm/src/shapes/text_allocation.rs +++ b/render-wasm/src/shapes/text_allocation.rs @@ -1,6 +1,8 @@ //! Equal division of an adjustment over capped opportunities, shared by the //! horizontal and vertical justification and squeezing passes. +use crate::shapes::text_policy::TextLayoutPolicy; + /// Amounts below this count as zero when dividing adjustments. const ALLOCATION_EPSILON: f32 = 0.0001; @@ -38,3 +40,45 @@ pub(crate) fn capped_equal_allocations( } allocations } + +/// Priority and pixel cap of the justification gap between `before` and +/// `after`, at the smaller adjacent font size `em`, or `None` when the gap +/// takes no expansion. Distribution (`distribute_budget`, the line or column +/// budget) fills every gap at one priority, capped only by that budget; +/// otherwise the policy bounds the gap (`TextLayoutPolicy::expansion_em`). +pub(crate) fn bounded_expansion( + policy: TextLayoutPolicy, + before: char, + after: char, + em: f32, + distribute_budget: Option, +) -> Option<(u8, f32)> { + let (priority, capacity) = match distribute_budget { + Some(budget) => (1, budget / em.max(0.01)), + None => policy.expansion_em(before, after), + }; + (priority > 0).then_some((priority, capacity * em)) +} + +/// Divide `slack` over bounded justification `opportunities` (index, +/// priority, cap in px): priority 1 first, then priority 2 with what is +/// left, equally within a priority. Returns (index, amount) per allocation. +pub(crate) fn allocate_bounded_expansion( + opportunities: &[(usize, u8, f32)], + slack: f32, +) -> Vec<(usize, f32)> { + let mut remaining = slack; + let mut allocations = Vec::new(); + for priority in 1..=2 { + let caps: Vec<_> = opportunities + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(index, _, cap)| (*index, *cap)) + .collect(); + for (index, amount) in capped_equal_allocations(&caps, remaining) { + allocations.push((index, amount)); + remaining -= amount; + } + } + allocations +} diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs index b2524be556..a362a1946e 100644 --- a/render-wasm/src/shapes/text_horizontal/justify.rs +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -5,7 +5,9 @@ // 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_allocation::{ + allocate_bounded_expansion, bounded_expansion, capped_equal_allocations, +}; use crate::shapes::text_paragraph::LayoutParagraph; use super::*; @@ -118,7 +120,7 @@ pub(crate) fn horizontal_pairwise_justify_spacing( lines.len().saturating_sub(1) }; for line in lines.iter().take(count) { - let mut remaining = (width - line.width as f32 - JUSTIFY_EPSILON).max(0.0); + let remaining = (width - line.width as f32 - JUSTIFY_EPSILON).max(0.0); let mut opportunities = Vec::new(); let start = chars.partition_point(|c| c.utf16 < line.start_index); let end = chars.partition_point(|c| c.utf16 < line.end_excluding_whitespaces); @@ -148,30 +150,16 @@ pub(crate) fn horizontal_pairwise_justify_spacing( { continue; } - let (priority, capacity) = if distribute { - (1, width / before.font_size.min(after.font_size).max(0.01)) - } else { - policy.expansion_em(before.ch, after.ch) - }; - if priority > 0 { - opportunities.push(( - index, - priority, - capacity * before.font_size.min(after.font_size), - )); + let em = before.font_size.min(after.font_size); + if let Some((priority, cap)) = + bounded_expansion(policy, before.ch, after.ch, em, distribute.then_some(width)) + { + opportunities.push((index, priority, cap)); } } - for priority in 1..=2 { - let caps: Vec<_> = opportunities - .iter() - .filter(|(_, p, _)| *p == priority) - .map(|(i, _, cap)| (*i, *cap)) - .collect(); - for (index, amount) in capped_equal_allocations(&caps, remaining) { - let ch = &chars[index]; - result[ch.span].push((ch.index, amount)); - remaining -= amount; - } + for (index, amount) in allocate_bounded_expansion(&opportunities, remaining) { + let ch = &chars[index]; + result[ch.span].push((ch.index, amount)); } } result diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 11d5c24a46..ca852abd62 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -148,14 +148,16 @@ fn horizontal_pairwise_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec LayoutParagraph, - ) { - use unicode_segmentation::UnicodeSegmentation; - let policy = paragraph.layout_policy(); - if !policy.reopens_overlong_words() - || !width.is_finite() - || width <= 0.0 - || width == f32::MAX - { - return; - } - let source: String = paragraph - .children() - .iter() - .map(TextSpan::apply_text_transform) - .collect(); - let mut utf16 = 0; - let graphemes: Vec<_> = source - .graphemes(true) - .map(|g| { - let at = utf16; - utf16 += g.encode_utf16().count(); - (at, g.chars().next().unwrap()) - }) - .collect(); - let mut offset = 0; - let protected: Vec<_> = paragraph - .children() - .iter() - .filter_map(|span| { - let start = offset; - offset += span.apply_text_transform().encode_utf16().count(); - (span.has_ruby() || span.is_warichu()).then_some(start..offset) - }) - .collect(); - let mut units = Vec::new(); - let mut start = 0; - for at in 1..graphemes.len() { - if policy.break_allowed(graphemes[at - 1].1, graphemes[at].1) { - if at - start > 1 { - units.push(start..at); - } - start = at; - } - } - if graphemes.len() - start > 1 { - units.push(start..graphemes.len()); - } - if units.is_empty() { - return; - } - let measured = measure(self); - let line_starts: Vec<_> = measured - .get_line_metrics() - .iter() - .skip(1) - .map(|line| line.start_index) - .collect(); - let mut emergency = Vec::new(); - for unit in units { - let first = graphemes[unit.start].0; - let last = graphemes.get(unit.end).map_or(utf16, |g| g.0); - let from = self.offsets.transformed_to_builder(first); - let to = self.offsets.transformed_to_builder(last); - let split = line_starts.partition_point(|at| *at <= from); - if line_starts.get(split).is_none_or(|at| *at >= to) { - continue; - } - let extent: f32 = measured - .get_rects_for_range(from..to, RectHeightStyle::Tight, RectWidthStyle::Tight) - .iter() - .map(|box_| box_.rect.width()) - .sum(); - if extent <= width + 0.01 { - continue; - } - for at in unit.start + 1..unit.end { - let (position, after) = graphemes[at]; - let before = graphemes[at - 1].1; - if policy.emergency_break_allowed(before, after) - && protected - .get(protected.partition_point(|r| r.end <= position)) - .is_none_or(|r| position <= r.start) - { - emergency.push(position); - } - } - } - if !emergency.is_empty() { - *self = Self::with_emergency_breaks(paragraph, &emergency); - } - } - - fn with_emergency_breaks(paragraph: &Paragraph, emergency: &[usize]) -> Self { + pub(super) fn with_emergency_breaks(paragraph: &Paragraph, emergency: &[usize]) -> Self { Self::with_breaks(paragraph, emergency, &[]) } - fn with_breaks(paragraph: &Paragraph, emergency: &[usize], forced: &[usize]) -> Self { + pub(super) fn with_breaks( + paragraph: &Paragraph, + emergency: &[usize], + forced: &[usize], + ) -> Self { let (texts, offset_map) = layout_span_texts_with_breaks(paragraph, emergency, forced); let pair_spacing = horizontal_pair_spacing(paragraph, &texts); let ruby_spacing = horizontal_ruby_spacing(paragraph, &texts, &offset_map); diff --git a/render-wasm/src/shapes/text_horizontal/overflow.rs b/render-wasm/src/shapes/text_horizontal/overflow.rs index 106f114b26..685ca48589 100644 --- a/render-wasm/src/shapes/text_horizontal/overflow.rs +++ b/render-wasm/src/shapes/text_horizontal/overflow.rs @@ -1,9 +1,119 @@ //! Repair SkParagraph's emergency splits without changing the wrap budget. use super::*; +use crate::shapes::line_breaks::PolicyBoundaries; use crate::shapes::text_paragraph::LayoutParagraph; +/// The paragraph's span texts after text transform, joined. +fn transformed_text(paragraph: &Paragraph) -> String { + paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform) + .collect() +} + +/// UTF-16 ranges, in the transformed text, of the spans whose ruby or +/// warichu must stay whole. +fn annotation_ranges(paragraph: &Paragraph) -> Vec> { + let mut offset = 0; + paragraph + .children() + .iter() + .filter_map(|span| { + let start = offset; + offset += span.apply_text_transform().encode_utf16().count(); + (span.has_ruby() || span.is_warichu()).then_some(start..offset) + }) + .collect() +} + +/// True when `position` falls strictly inside one of the ascending +/// `annotations` ranges. +fn within_annotation(annotations: &[std::ops::Range], position: usize) -> bool { + annotations + .get(annotations.partition_point(|r| r.end <= position)) + .is_some_and(|r| r.start < position) +} + +/// A finite, real wrap width; auto-width and unbounded layout skip repairs. +fn is_wrap_width(width: f32) -> bool { + width.is_finite() && width > 0.0 && width != f32::MAX +} + impl HorizontalParagraphPlan { + /// SkParagraph's emergency fallback can ignore punctuation joins inside + /// an overlong word. Measure whole units once, then reopen only safe + /// grapheme boundaries of units that cannot fit an empty line. The + /// replacement markers have identical UTF-8/UTF-16 lengths, so every + /// source/builder offset remains identical to the unrelaxed plan. + pub(crate) fn relax_overlong_words( + &mut self, + paragraph: &Paragraph, + width: f32, + measure: impl FnOnce(&Self) -> LayoutParagraph, + ) { + let policy = paragraph.layout_policy(); + if !policy.reopens_overlong_words() || !is_wrap_width(width) { + return; + } + let boundaries = PolicyBoundaries::new(&transformed_text(paragraph), policy); + let graphemes = &boundaries.graphemes; + let annotations = annotation_ranges(paragraph); + let mut units = Vec::new(); + let mut start = 0; + for (at, grapheme) in graphemes.iter().enumerate().skip(1) { + if grapheme.allowed { + if at - start > 1 { + units.push(start..at); + } + start = at; + } + } + if graphemes.len() - start > 1 { + units.push(start..graphemes.len()); + } + if units.is_empty() { + return; + } + let measured = measure(self); + let line_starts: Vec<_> = measured + .get_line_metrics() + .iter() + .skip(1) + .map(|line| line.start_index) + .collect(); + let mut emergency = Vec::new(); + for unit in units { + let first = graphemes[unit.start].utf16; + let last = graphemes + .get(unit.end) + .map_or(boundaries.length, |g| g.utf16); + let from = self.offsets.transformed_to_builder(first); + let to = self.offsets.transformed_to_builder(last); + let split = line_starts.partition_point(|at| *at <= from); + if line_starts.get(split).is_none_or(|at| *at >= to) { + continue; + } + let extent: f32 = measured + .get_rects_for_range(from..to, RectHeightStyle::Tight, RectWidthStyle::Tight) + .iter() + .map(|box_| box_.rect.width()) + .sum(); + if extent <= width + 0.01 { + continue; + } + for grapheme in &graphemes[unit.start + 1..unit.end] { + if grapheme.emergency && !within_annotation(&annotations, grapheme.utf16) { + emergency.push(grapheme.utf16); + } + } + } + if !emergency.is_empty() { + *self = Self::with_emergency_breaks(paragraph, &emergency); + } + } + /// Keep an indivisible unit together even when Skia's last-resort wrapping /// ignores its joiners. Isolate overflowing units, measure the surrounding /// text at the original width, and freeze those legal line starts before @@ -14,57 +124,32 @@ impl HorizontalParagraphPlan { width: f32, mut measure: impl FnMut(&Self, f32) -> LayoutParagraph, ) { - use unicode_segmentation::UnicodeSegmentation; let policy = paragraph.layout_policy(); - if !policy.keeps_overlong_units() || !width.is_finite() || width <= 0.0 || width == f32::MAX - { + if !policy.keeps_overlong_units() || !is_wrap_width(width) { return; } - let source: String = paragraph - .children() - .iter() - .map(TextSpan::apply_text_transform) - .collect(); - let mut length = 0; - let graphemes: Vec<_> = source - .graphemes(true) - .map(|g| { - let start = length; - length += g.encode_utf16().count(); - (start, g.chars().next().unwrap()) - }) - .collect(); - let mut offset = 0; - let protected: Vec<_> = paragraph - .children() - .iter() - .filter_map(|span| { - let start = offset; - offset += span.apply_text_transform().encode_utf16().count(); - (span.has_ruby() || span.is_warichu()).then_some(start..offset) - }) - .collect(); + let boundaries = PolicyBoundaries::new(&transformed_text(paragraph), policy); + let graphemes = &boundaries.graphemes; + let length = boundaries.length; + let annotations = annotation_ranges(paragraph); let hard_break = |c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}'); let mut units = Vec::new(); let mut start = 0; for at in 1..graphemes.len() { - let (position, after) = graphemes[at]; - let before = graphemes[at - 1].1; - let annotation = protected - .get(protected.partition_point(|r| r.end <= position)) - .is_some_and(|r| r.start < position); + let position = graphemes[at].utf16; + let (before, after) = (graphemes[at - 1].first, graphemes[at].first); if hard_break(before) || hard_break(after) - || (!annotation && policy.emergency_break_allowed(before, after)) + || (!within_annotation(&annotations, position) && graphemes[at].emergency) { if at - start > 1 { - units.push(graphemes[start].0..position); + units.push(graphemes[start].utf16..position); } start = at; } } if graphemes.len() - start > 1 { - units.push(graphemes[start].0..length); + units.push(graphemes[start].utf16..length); } if units.is_empty() { return; @@ -107,9 +192,11 @@ impl HorizontalParagraphPlan { at > 0 && at < length && graphemes - .binary_search_by_key(&at, |g| g.0) + .binary_search_by_key(&at, |g| g.utf16) .ok() - .is_some_and(|i| !hard_break(graphemes[i].1) && !hard_break(graphemes[i - 1].1)) + .is_some_and(|i| { + !hard_break(graphemes[i].first) && !hard_break(graphemes[i - 1].first) + }) }; let mut forced: Vec<_> = units .iter() diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index 7991c49c9e..3dec4a87fb 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -1,6 +1,5 @@ use crate::render::FallbackFonts; use crate::shapes::text_paragraph::LayoutParagraph; -use std::collections::HashMap; use super::*; @@ -17,6 +16,25 @@ pub(crate) fn horizontal_zhuyin_origin( ) } +/// Zhuyin glyphs of a horizontal base whose builder text starts at UTF-16 +/// `base_start` and whose first line rect is `rect`, with the strip's +/// top-left in the laid-out paragraph. Position data, painting and bounds +/// share it. +fn horizontal_zhuyin_glyphs( + span: &TextSpan, + syllable: &super::super::text_zhuyin::Syllable, + laid_out: &LayoutParagraph, + lines: &[skia::textlayout::LineMetrics], + base_start: usize, + rect: skia::Rect, + fonts: (&skia::textlayout::TypefaceFontProvider, &FallbackFonts), +) -> (skia::Point, Vec) { + let em_top = horizontal_base_em_top(laid_out, lines, base_start, span.font_size, rect); + let origin = horizontal_zhuyin_origin(span, rect, em_top); + let glyphs = super::super::text_zhuyin::shape(span, syllable, fonts.0, fonts.1); + (origin, glyphs) +} + pub(crate) fn horizontal_zhuyin_entries( paragraph: &Paragraph, offsets: &HorizontalOffsets, @@ -40,11 +58,17 @@ pub(crate) fn horizontal_zhuyin_entries( let Some(rect) = rects.first() else { continue; }; - let em_top = - horizontal_base_em_top(laid_out, &lines, base_start, span.font_size, rect.rect); - let origin = horizontal_zhuyin_origin(span, rect.rect, em_top); + let (origin, glyphs) = horizontal_zhuyin_glyphs( + span, + &syllable, + laid_out, + &lines, + base_start, + rect.rect, + (provider, fallbacks), + ); let size = span.ruby_font_size(); - for glyph in super::super::text_zhuyin::shape(span, &syllable, provider, fallbacks) { + for glyph in glyphs { entries.push(crate::shapes::PositionData { paragraph: 0, span: index as u32, @@ -173,8 +197,7 @@ pub(super) fn horizontal_ruby_spacing_with( .flat_map(|run| run.advances.iter().copied()) .collect() }; - // Overhang an earlier reading already takes on a character after it. - let mut claimed: HashMap<(usize, usize), f32> = HashMap::new(); + let mut claims = OverhangClaims::new(); let mut span_start = 0usize; for (index, (span, text)) in spans.iter().zip(texts).enumerate() { let text_start = span_start; @@ -240,16 +263,14 @@ pub(super) fn horizontal_ruby_spacing_with( ) }) }; - let taken = previous - .and_then(|(owner, char_index, _)| claimed.get(&(owner, char_index))) - .copied() - .unwrap_or(0.0); - let rooms = ((room(previous) - taken).max(0.0), room(next)); + let key = |(owner, char_index, _): (usize, usize, char)| (owner, char_index); + let rooms = claims.room( + previous.map(key), + next.map(key), + (room(previous), room(next)), + ruby_length - base_width, + ); spacing.rooms[index] = rooms; - if let Some((owner, char_index, _)) = next { - let hang_after = ruby_overhangs(ruby_length - base_width, rooms).1; - claimed.insert((owner, char_index), hang_after); - } let gap = long_ruby_gap(ruby_length, base_width, rooms, base.len()); if gap <= 0.0 { continue; @@ -534,15 +555,16 @@ fn visit_horizontal_ruby_glyphs( ); if let Some(syllable) = super::super::text_zhuyin::syllable(span) { if let Some(rect) = rects.first() { - let em_top = - horizontal_base_em_top(laid_out, &lines, base_start, span.font_size, rect.rect); - let origin = horizontal_zhuyin_origin(span, rect.rect, em_top); - for glyph in super::super::text_zhuyin::shape( + let (origin, glyphs) = horizontal_zhuyin_glyphs( span, &syllable, - font_provider, - fallback_families, - ) { + laid_out, + &lines, + base_start, + rect.rect, + (font_provider, fallback_families), + ); + for glyph in glyphs { visit( &glyph.run.font, glyph.run.glyphs[glyph.glyph], diff --git a/render-wasm/src/shapes/text_policy.rs b/render-wasm/src/shapes/text_policy.rs index 76dfbf883e..ea4918250c 100644 --- a/render-wasm/src/shapes/text_policy.rs +++ b/render-wasm/src/shapes/text_policy.rs @@ -2,6 +2,9 @@ //! Regional presets use CLREQ (2026-10-07); Korean restrictions use KLREQ //! (2026-03-21). These are named presets, not universal regional mandates. +use super::cjk_chars::{ + is_han, COMMAS_AND_FULL_STOPS, FULLWIDTH_CLOSING_BRACKETS, FULLWIDTH_OPENING_BRACKETS, +}; use super::jlreq::{ break_allowed_between, forbidden_at_line_end, forbidden_at_line_start, keeps_together_classified, pair_rule, JlreqClass, @@ -40,6 +43,15 @@ pub enum TextLayoutPolicy { Korean(KoreanWrap), } +/// Spacing between two adjacent characters of a pairwise policy, in em. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct PairSpacing { + /// Removable punctuation side bearings. + pub compression: f32, + /// Han/Latin gap to add. + pub script_gap: f32, +} + /// The parts of a BCP 47 language tag the layout and font choices read. /// Subtags may be separated by `-` or `_`; matching ignores case. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -93,20 +105,18 @@ impl<'a> LanguageTag<'a> { } } -pub fn is_han(ch: char) -> bool { - matches!(ch as u32, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af) -} - fn numeric(ch: char) -> bool { ch.is_numeric() || "一二三四五六七八九十百千萬万億亿〇零".contains(ch) } fn opening(ch: char) -> bool { - "([{〈《「『【〔〖〘〚‘“([{".contains(ch) + FULLWIDTH_OPENING_BRACKETS.contains(ch) || "‘“([{".contains(ch) } fn closing(ch: char) -> bool { - ",。、.!?:;)]}〉》」』】〕〗〙〛’”)]},.!?:;".contains(ch) + COMMAS_AND_FULL_STOPS.contains(ch) + || FULLWIDTH_CLOSING_BRACKETS.contains(ch) + || "!?:;’”)]},.!?:;".contains(ch) } fn protected(ch: char) -> bool { @@ -263,13 +273,13 @@ impl TextLayoutPolicy { else { return (0.0, 0.0); }; - if "([{〈《「『【〔〖〘〚".contains(ch) { + if FULLWIDTH_OPENING_BRACKETS.contains(ch) { return (0.5, 0.0); } - if ")]}〉》」』】〕〗〙〛".contains(ch) { + if FULLWIDTH_CLOSING_BRACKETS.contains(ch) { return (0.0, 0.5); } - let adjustable = ",。、.".contains(ch) + let adjustable = COMMAS_AND_FULL_STOPS.contains(ch) || (!vertical && region == ChineseRegion::Mainland && ":;!?".contains(ch)) || (!vertical && region == ChineseRegion::HongKong && ":;".contains(ch)); if !adjustable { @@ -294,6 +304,16 @@ impl TextLayoutPolicy { } } + /// Pairwise spacing between two adjacent characters, in em: punctuation + /// compression to remove and the script gap to add. Each axis applies + /// its own caps and skips. + pub fn pair_spacing_em(self, before: char, after: char, vertical: bool) -> PairSpacing { + PairSpacing { + compression: self.compression_em(before, after, vertical), + script_gap: self.script_spacing_em(before, after), + } + } + /// This preset permits hanging only in vertical traditional composition. /// Consecutive marks are excluded by the planner. Horizontal CN uses /// compression and push-out; no preset silently enables horizontal hanging. @@ -303,7 +323,7 @@ impl TextLayoutPolicy { self, Self::Chinese(ChineseRegion::Taiwan | ChineseRegion::HongKong) ) - && ",。、.".contains(ch) + && COMMAS_AND_FULL_STOPS.contains(ch) { 0.5 } else { diff --git a/render-wasm/src/shapes/text_ruby.rs b/render-wasm/src/shapes/text_ruby.rs index 875e5f0075..465448ebb9 100644 --- a/render-wasm/src/shapes/text_ruby.rs +++ b/render-wasm/src/shapes/text_ruby.rs @@ -1,6 +1,9 @@ //! Ruby (furigana, Pinyin) piece, distribution and overhang rules shared by //! the horizontal and vertical engines (JLREQ §3.3). +use std::collections::HashMap; +use std::hash::Hash; + use crate::shapes::jlreq::{classify, JlreqClass}; use crate::shapes::{RubyAlign, RubyOverhang}; @@ -54,6 +57,43 @@ pub(crate) fn ruby_overhangs(overflow: f32, room: (f32, f32)) -> (f32, f32) { (hang_before, (overflow - hang_before).clamp(0.0, after)) } +/// Overhang that earlier readings already take on the character after them. +/// A kana between two readings lends the later one only what the earlier one +/// leaves (JLREQ Fig 138), so the two readings never meet over it. `K` +/// identifies a neighbouring character. +pub(crate) struct OverhangClaims { + claimed: HashMap, +} + +impl OverhangClaims { + pub(crate) fn new() -> Self { + Self { + claimed: HashMap::new(), + } + } + + /// Room (before, after) left for a reading `overflow` longer than its + /// base, given the `room` its neighbours `before` and `after` offer, in + /// reading order. Records how far the reading hangs over `after`. + pub(crate) fn room( + &mut self, + before: Option, + after: Option, + room: (f32, f32), + overflow: f32, + ) -> (f32, f32) { + let taken = before + .and_then(|key| self.claimed.get(&key)) + .copied() + .unwrap_or(0.0); + let room = ((room.0 - taken).max(0.0), room.1); + if let Some(after) = after { + self.claimed.insert(after, ruby_overhangs(overflow, room).1); + } + room + } +} + /// Gap that spreads a base under a reading longer than itself (JLREQ /// §3.3.8). What the reading set solid (`ruby_length`) overflows the base /// by, less the overhang `room`, becomes one gap per base character: a full diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs index 20e159795a..dbe3df7be3 100644 --- a/render-wasm/src/shapes/text_vertical/annotations.rs +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -9,7 +9,7 @@ use skia_safe::{self as skia, Font}; 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, + distribute_ruby_pieces, long_ruby_gap, ruby_overhang_room, ruby_pieces, OverhangClaims, }; use crate::shapes::{ AnnotationSide, AppliedTextTransform, FontFeatures, Paragraph, RubyOverhang, TextContent, @@ -208,7 +208,7 @@ pub(super) fn set_ruby_overhang_rooms(flow: &[FlowCell], ruby_units: &mut [RubyB .is_some_and(|unit| ruby_units[*unit].annotates(cell)) }; let indices = annotated_indices(flow.iter().map(|item| &item.cell), ruby_units); - let mut claimed: HashMap = HashMap::new(); + let mut claims = OverhangClaims::new(); let rooms: Vec<(f32, f32)> = ruby_units .iter() .zip(&indices) @@ -229,13 +229,13 @@ pub(super) fn set_ruby_overhang_rooms(flow: &[FlowCell], ruby_units: &mut [RubyB }; let before = first.checked_sub(1); let after = Some(last + 1).filter(|index| *index < flow.len()); - let taken = before.and_then(|index| claimed.get(&index)).copied(); - let room = ((room(before) - taken.unwrap_or(0.0)).max(0.0), room(after)); - if let Some(after) = after { - let base: f32 = indices.iter().map(|index| flow[*index].cell.extent).sum(); - claimed.insert(after, ruby_overhangs(unit.ruby_line() - base, room).1); - } - room + let base: f32 = indices.iter().map(|index| flow[*index].cell.extent).sum(); + claims.room( + before, + after, + (room(before), room(after)), + unit.ruby_line() - base, + ) }) .collect(); for (unit, room) in ruby_units.iter_mut().zip(rooms) { diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index 8addeea3a0..8264730156 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -3,12 +3,16 @@ // (aki, oikomi, inter-script spacing) and pick column breaks (kinsoku, // burasage, oidashi). +use crate::shapes::cjk_chars::is_punctuation; use crate::shapes::jlreq::{ aki_class, break_allowed_between, classify, pair_rule, punctuation_aki_sheds, AkiShed, JlreqClass, }; use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start}; -use crate::shapes::text_allocation::capped_equal_allocations; +use crate::shapes::line_breaks::PolicyBoundaries; +use crate::shapes::text_allocation::{ + allocate_bounded_expansion, bounded_expansion, capped_equal_allocations, +}; use crate::shapes::text_policy::TextLayoutPolicy; use crate::shapes::{LineAdjustment, TextAlign}; use unicode_segmentation::UnicodeSegmentation; @@ -293,34 +297,21 @@ pub(super) fn plan_pairwise_columns( policy: TextLayoutPolicy, max_height: f32, ) -> Vec<(usize, f32)> { - let mut boundaries = Vec::new(); - let mut offset = 0; - let mut previous = None; - for grapheme in text.graphemes(true) { - let first = grapheme.chars().next().unwrap(); - let allowed = previous.is_none_or(|before| policy.break_allowed(before, first)); - let emergency = previous.is_none_or(|before| policy.emergency_break_allowed(before, first)); - boundaries.push((offset, allowed, emergency)); - previous = Some(first); - offset += grapheme.encode_utf16().count(); - } - boundaries.push((offset, true, true)); + let boundaries = PolicyBoundaries::new(text, policy); let mut emergency_start = Vec::with_capacity(cells.len()); let mut unit_start = Vec::with_capacity(cells.len()); let mut prefix = vec![0.0]; for (i, flow) in cells.iter().enumerate() { - let boundary = boundaries - .binary_search_by_key(&flow.cell.start, |b| b.0) - .ok(); + let boundary = boundaries.at(flow.cell.start); let atomic = i > 0 && (flow.keep_with_previous || flow.shares_previous_box || boundary.is_none()); - let emergency_joined = atomic || boundary.is_some_and(|at| !boundaries[at].2); + let emergency_joined = atomic || boundary.is_some_and(|(_, emergency)| !emergency); emergency_start.push(if i > 0 && emergency_joined { emergency_start[i - 1] } else { i }); - let joined = atomic || boundary.is_some_and(|at| !boundaries[at].1); + let joined = atomic || boundary.is_some_and(|(allowed, _)| !allowed); unit_start.push(if i > 0 && joined { unit_start[i - 1] } else { @@ -339,12 +330,7 @@ pub(super) fn plan_pairwise_columns( let mut placements = Vec::with_capacity(cells.len()); let (mut column, mut column_start, mut cursor) = (0, 0, 0.0); for (i, flow) in cells.iter().enumerate() { - let punctuation = |at: usize| { - cells - .get(at) - .and_then(|c| c.ch) - .is_some_and(|c| ",。、.!?:;…‥—―「」『』()〔〕[]{}〈〉《》【】〖〗〘〙〚〛‘’“”.,;!?:()[]{}".contains(c)) - }; + let punctuation = |at: usize| cells.get(at).and_then(|c| c.ch).is_some_and(is_punctuation); let solitary = (i == 0 || !punctuation(i - 1)) && !punctuation(i + 1); let hang = if solitary { flow.ch @@ -425,7 +411,8 @@ pub(super) fn apply_pairwise_spacing(cells: &mut [FlowCell], text: &str, policy: - cells[i - 1].trailing_spacing - 0.5 * cells[i - 1].cell.font_size) .max(0.0); - let compression = (policy.compression_em(before, after, true) * em).min(available); + let spacing = policy.pair_spacing_em(before, after, true); + let compression = (spacing.compression * em).min(available); // A tate-chu-yoko composite sets in one ideograph's cell, so the // Han/Western gap of its digits or letters does not apply. let tate_chu_yoko = @@ -433,7 +420,7 @@ pub(super) fn apply_pairwise_spacing(cells: &mut [FlowCell], text: &str, policy: let gap = if tate_chu_yoko(&cells[i - 1]) || tate_chu_yoko(&cells[i]) { 0.0 } else { - policy.script_spacing_em(before, after) * em + spacing.script_gap * em }; cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression) .max(cells[i - 1].trailing_spacing.max(0.0)); @@ -475,7 +462,7 @@ pub(super) fn pairwise_aligned_tops( if (align == TextAlign::Distribute || (align == TextAlign::Justify && column < last_column)) && is_bounded(budget) { - let mut remaining = (budget - used).max(0.0); + let remaining = (budget - used).max(0.0); let mut extras = vec![0.0; end - start]; let mut opportunities = Vec::new(); for i in start..end - 1 { @@ -495,34 +482,16 @@ pub(super) fn pairwise_aligned_tops( { continue; } - let (priority, cap) = if align == TextAlign::Distribute { - ( - 1, - budget - / cells[i] - .cell - .font_size - .min(cells[i + 1].cell.font_size) - .max(0.01), - ) - } else { - policy.expansion_em(before, after) - }; let em = cells[i].cell.font_size.min(cells[i + 1].cell.font_size); - if priority > 0 { - opportunities.push((i, priority, cap * em)); + let distribute = (align == TextAlign::Distribute).then_some(budget); + if let Some((priority, cap)) = + bounded_expansion(policy, before, after, em, distribute) + { + opportunities.push((i, priority, cap)); } } - for priority in 1..=2 { - let caps: Vec<_> = opportunities - .iter() - .filter(|(_, p, _)| *p == priority) - .map(|(i, _, cap)| (*i, *cap)) - .collect(); - for (i, amount) in capped_equal_allocations(&caps, remaining) { - extras[i - start] += amount; - remaining -= amount; - } + for (i, amount) in allocate_bounded_expansion(&opportunities, remaining) { + extras[i - start] += amount; } let mut extra = 0.0; for i in start..end { diff --git a/render-wasm/src/shapes/text_vertical/orientation.rs b/render-wasm/src/shapes/text_vertical/orientation.rs index 2e20eb98fd..941e2a5c83 100644 --- a/render-wasm/src/shapes/text_vertical/orientation.rs +++ b/render-wasm/src/shapes/text_vertical/orientation.rs @@ -12,9 +12,10 @@ pub(crate) fn is_emoji_char(c: char) -> bool { ) } -/// Characters that stay upright in vertical flow: kana, kanji, CJK -/// punctuation, full-width forms and emoji. Everything else rotates sideways -/// under `text-orientation: mixed`. +/// Characters that stay upright in vertical flow: kana, kanji, hangul, +/// bopomofo, CJK punctuation, full-width forms and emoji. Everything else +/// rotates sideways under `text-orientation: mixed`. A table of its own: it +/// leaves half-width katakana sideways, unlike `cjk_chars::is_kana`. pub(crate) fn is_upright_char(c: char) -> bool { is_emoji_char(c) || matches!(u32::from(c), diff --git a/render-wasm/src/shapes/text_zhuyin.rs b/render-wasm/src/shapes/text_zhuyin.rs index 72a13384f3..826bb4d07d 100644 --- a/render-wasm/src/shapes/text_zhuyin.rs +++ b/render-wasm/src/shapes/text_zhuyin.rs @@ -4,6 +4,7 @@ use std::ops::Range; use skia_safe::{self as skia, textlayout::TypefaceFontProvider, FontMgr}; +use super::cjk_chars::is_zhuyin_base; use super::text_vertical::{shape_segment_with_fallbacks, span_font_families, ShapedRun}; use super::TextSpan; use crate::render::FallbackFonts; @@ -89,7 +90,7 @@ pub(crate) fn single_han(text: &str) -> bool { let Some(first) = chars.next() else { return false; }; - matches!(first as u32, 0x3400..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af) + is_zhuyin_base(first) && chars.next().is_none_or(|ch| { matches!(ch as u32, 0xfe00..=0xfe0f | 0xe0100..=0xe01ef) && chars.next().is_none() })