mirror of
https://github.com/penpot/penpot.git
synced 2026-11-06 10:00:38 +00:00
♻️ 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:
parent
2b888f712b
commit
fc0f7d72a2
@ -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)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -11,6 +11,7 @@ use std::iter::once;
|
||||
mod blend;
|
||||
mod blurs;
|
||||
mod bools;
|
||||
pub mod cjk_chars;
|
||||
mod corners;
|
||||
mod fills;
|
||||
mod fonts;
|
||||
|
||||
64
render-wasm/src/shapes/cjk_chars.rs
Normal file
64
render-wasm/src/shapes/cjk_chars.rs
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -148,14 +148,16 @@ fn horizontal_pairwise_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec<V
|
||||
let b = ¶graph.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);
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user