♻️ Share duplicated CJK layout helpers across both axes

Several CJK passes had a copy per writing direction. Merge them without
changing their results:

- allocate_bounded_expansion and bounded_expansion divide justify and
  distribute slack for both axes. Each axis still collects its own gaps;
  the horizontal one checks no-break controls over the source range and
  the vertical one at cell edges, as before.
- PolicyBoundaries holds the break and emergency rules at each grapheme
  of a paragraph. The horizontal overlong-word and atomic-overflow passes
  and the vertical pairwise column planner read it. relax_overlong_words
  moves next to retain_atomic_overflow and shares annotation_ranges.
- TextLayoutPolicy::pair_spacing_em gives the compression and script gap
  that both axes apply with their own caps.
- cjk_chars keeps the Han, kana, hangul and punctuation ranges. Ranges
  that differ on purpose stay separate there, with a note on why.
- OverhangClaims tracks the ruby overhang an earlier reading takes.
- horizontal_zhuyin_glyphs places Zhuyin for position data and painting.
- paint_horizontal_annotations paints warichu, emphasis and ruby; the
  emoji overlay keeps its place between the marks and the ruby.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-11-05 16:00:48 +01:00
parent 2b888f712b
commit fc0f7d72a2
17 changed files with 514 additions and 335 deletions

View File

@ -2,6 +2,7 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr};
use std::collections::{BTreeSet, HashMap, HashSet};
use crate::error::{Error, Result};
use crate::shapes::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)]

View File

@ -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,
&para.paragraph,
para.x,
para.y,
);
crate::shapes::paint_horizontal_emphasis(
canvas,
source_paragraph,
plan,
&para.paragraph,
para.x,
para.y,
);
}
let (index, origin) = (para.source_paragraph, (para.x, para.y));
paint_horizontal_notes_and_marks(canvas, text_content, index, &para.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,
&para.paragraph,
para.x,
para.y,
origin.0,
origin.1,
);
for deco in &para.decorations {

View File

@ -11,6 +11,7 @@ use std::iter::once;
mod blend;
mod blurs;
mod bools;
pub mod cjk_chars;
mod corners;
mod fills;
mod fonts;

View File

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

View File

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

View File

@ -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<PolicyBoundary>,
/// 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<char> = 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)]

View File

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

View File

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

View File

@ -148,14 +148,16 @@ fn horizontal_pairwise_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec<V
let b = &paragraph.children()[span];
if !a.has_ruby() && !b.has_ruby() && !a.is_warichu() && !b.is_warichu() {
let em = a.font_size.min(b.font_size);
let spacing = policy.pair_spacing_em(before, ch, false);
// `palt` already sets punctuation proportionally.
let compression = if a.font_features == FontFeatures::Palt
|| b.font_features == FontFeatures::Palt
{
0.0
} else {
policy.compression_em(before, ch, false)
spacing.compression
};
let amount = (compression - policy.script_spacing_em(before, ch)) * em;
let amount = (compression - spacing.script_gap) * em;
if amount != 0.0 {
result[before_span].push((before_index, amount));
}
@ -354,113 +356,15 @@ impl HorizontalParagraphPlan {
Self::with_emergency_breaks(paragraph, &[])
}
/// 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,
) {
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);

View File

@ -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<std::ops::Range<usize>> {
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<usize>], 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()

View File

@ -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<super::super::text_zhuyin::Glyph>) {
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],

View File

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

View File

@ -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<K> {
claimed: HashMap<K, f32>,
}
impl<K: Eq + Hash> OverhangClaims<K> {
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<K>,
after: Option<K>,
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

View File

@ -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<usize, f32> = 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) {

View File

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

View File

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

View File

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