penpot/render-wasm/src/shapes/line_breaks.rs
alonso.torres fc0f7d72a2 ♻️ 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
2026-11-05 17:49:00 +01:00

804 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Layout-text rewrite for horizontal CJK line breaking.
//!
//! skia-safe exposes no ICU BreakIterator, so the layout policy steers
//! skparagraph's breaker by editing the text handed to it. U+2060 WORD
//! JOINER suppresses a break the policy forbids (Japanese kinsoku shori,
//! Chinese and Korean punctuation, nonbreaking units and annotations).
//! U+200B ZERO WIDTH SPACE reopens a break that UAX #14 forbids but the
//! policy allows, such as before … and ‥ in JLREQ, or an emergency break
//! inside an overlong unit. U+2028 LINE SEPARATOR keeps the original wrap
//! budget around an indivisible unit that overflows its box. In Japanese
//! text the same transform inserts the JLREQ quarter-em space at
//! Japanese/Western boundaries and sets Western word spaces to one third em.
//!
//! Inserted characters shift every UTF-16 offset the laid-out paragraph
//! reports (position-data, carets, selection rects). [`OffsetMap`]
//! translates between original and shifted offsets. It depends only on the
//! span texts, so any consumer can recompute it and match the builders.
/// Zero-width character; its UAX #14 class forbids a break on either side.
pub const WORD_JOINER: char = '\u{2060}';
/// ZERO WIDTH SPACE: opens a break UAX #14 forbids but JLREQ allows, before
/// an inseparable mark such as … (JLREQ only keeps two of them together).
pub const BREAK_OPPORTUNITY: char = '\u{200B}';
/// Layout-only line separator used to retain the original wrap budget when
/// an indivisible CJK unit needs more width than its container.
pub const FORCED_LINE_BREAK: char = '\u{2028}';
/// True for the zero-width characters this pass inserts to steer breaks;
/// they take no letter-spacing and count as no character.
pub fn is_inserted_zero_width(ch: char) -> bool {
ch == WORD_JOINER || ch == BREAK_OPPORTUNITY || ch == FORCED_LINE_BREAK
}
/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap.
pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}';
/// Unicode THREE-PER-EM SPACE, used for Western word spaces.
pub const WESTERN_WORD_SPACE: char = '\u{2004}';
/// Unicode IDEOGRAPHIC SPACE, the one-em space after a sentence-ending
/// question or exclamation mark.
pub const IDEOGRAPHIC_SPACE: char = '\u{3000}';
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)]
pub struct OffsetMap {
/// Ascending shifted UTF-16 indices of inserted (not substituted) chars.
inserted: Vec<usize>,
}
impl OffsetMap {
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.inserted.is_empty()
}
/// True when the character at a shifted offset was inserted by the
/// transform.
pub fn is_inserted(&self, shifted: usize) -> bool {
self.inserted.binary_search(&shifted).is_ok()
}
/// Original offset for a shifted offset. An offset on an inserted
/// character resolves to the boundary where it was inserted.
pub fn to_original(&self, shifted: usize) -> usize {
shifted - self.inserted.partition_point(|&p| p < shifted)
}
/// Shifted offset for an original offset. A boundary that received an
/// inserted character resolves after it, so carets skip synthetic spacing.
pub fn to_shifted(&self, original: usize) -> usize {
// The i-th insertion lands before `original` when it sits at or
// before `original + i`; `inserted[i] - i` never decreases, so the
// count is a partition point.
let (mut low, mut high) = (0, self.inserted.len());
while low < high {
let middle = (low + high) / 2;
if self.inserted[middle] - middle <= original {
low = middle + 1;
} else {
high = middle;
}
}
original + low
}
}
/// Applies the horizontal Japanese layout-text transform (the `Japanese`
/// policy of `apply_policy_with_breaks`): inserts WORD
/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space
/// between Japanese letters and Western letters or digits, and sets
/// breakable ASCII spaces to one third em. Span boundaries are transparent;
/// grapheme clusters are not, so nothing lands inside one. `ruby_breaks[span]
/// == Some(boundaries)` forbids breaks at every internal scalar boundary of
/// that span except those UTF-16 offsets. Returns `None` when nothing changes.
#[cfg(test)]
fn apply_to_span_texts_with_ruby_breaks(
span_texts: &[String],
ruby_breaks: &[Option<Vec<usize>>],
) -> Option<(Vec<String>, OffsetMap)> {
apply_policy_to_span_texts(span_texts, ruby_breaks, TextLayoutPolicy::Japanese)
}
#[cfg(test)]
pub fn apply_policy_to_span_texts(
span_texts: &[String],
ruby_breaks: &[Option<Vec<usize>>],
policy: TextLayoutPolicy,
) -> Option<(Vec<String>, OffsetMap)> {
apply_policy_with_breaks(span_texts, ruby_breaks, policy, &[], &[])
}
pub fn apply_policy_with_breaks(
span_texts: &[String],
ruby_breaks: &[Option<Vec<usize>>],
policy: TextLayoutPolicy,
emergency_breaks: &[usize],
forced_breaks: &[usize],
) -> Option<(Vec<String>, OffsetMap)> {
let jlreq = policy.uses_jlreq();
let source = span_texts.concat();
let boundaries: Vec<usize> = source.grapheme_indices(true).map(|(at, _)| at).collect();
let mut source_byte = 0;
let mut source_utf16 = 0;
let mut inserted: Vec<usize> = Vec::new();
let mut out: Vec<String> = Vec::with_capacity(span_texts.len());
// Base character of the previous grapheme cluster, with its class.
let mut prev: Option<(char, JlreqClass)> = None;
let mut changed = false;
// Running position in shifted UTF-16 coordinates.
let mut shifted_pos: usize = 0;
for (span_index, text) in span_texts.iter().enumerate() {
let mut shifted_text = String::with_capacity(text.len() + 4);
let mut local_utf16 = 0usize;
for c in text.chars() {
let at_boundary = boundaries.binary_search(&source_byte).is_ok();
source_byte += c.len_utf8();
let source_at = source_utf16;
source_utf16 += c.len_utf16();
if !at_boundary {
shifted_text.push(c);
shifted_pos += c.len_utf16();
local_utf16 += c.len_utf16();
continue;
}
let current = (c, classify(c));
let ruby_forbids_break = local_utf16 > 0
&& ruby_breaks
.get(span_index)
.and_then(Option::as_ref)
.is_some_and(|breaks| !breaks.contains(&local_utf16));
let forbid_break =
ruby_forbids_break || prev.is_some_and(|p| policy.needs_joiner(p, current));
let mut insert = |ch: char| {
shifted_text.push(ch);
inserted.push(shifted_pos);
shifted_pos += 1;
changed = true;
};
if jlreq && prev.is_some_and(|p| takes_em_space_after(p, current)) {
insert(IDEOGRAPHIC_SPACE);
}
if jlreq && prev.is_some_and(|p| is_japanese_western_boundary(p, current)) {
insert(JAPANESE_WESTERN_SPACE);
}
// After an inserted space, the joiner keeps the break blocked.
if forced_breaks.binary_search(&source_at).is_ok() {
insert(FORCED_LINE_BREAK);
} else if forbid_break {
insert(if emergency_breaks.binary_search(&source_at).is_ok() {
BREAK_OPPORTUNITY
} else {
WORD_JOINER
});
} else if jlreq
&& current.1 == JlreqClass::Inseparable
&& prev.is_some_and(|p| pair_rule(p.1, current.1).break_allowed)
{
insert(BREAK_OPPORTUNITY);
}
let layout_char = if policy.normalizes_western_spaces() && c == ' ' {
changed = true;
WESTERN_WORD_SPACE
} else {
c
};
shifted_text.push(layout_char);
shifted_pos += c.len_utf16();
local_utf16 += c.len_utf16();
prev = Some(current);
}
out.push(shifted_text);
}
if !changed {
return None;
}
Some((out, OffsetMap { inserted }))
}
/// JLREQ §3.2.6 boundary between Japanese text and a Western letter or
/// digit. Western symbols, brackets and emoji set solid.
fn is_japanese_western_boundary(
(before, before_class): (char, JlreqClass),
(after, after_class): (char, JlreqClass),
) -> bool {
(before_class.is_japanese_text() && after_class.is_western_run() && after.is_alphanumeric())
|| (before_class.is_western_run()
&& before.is_alphanumeric()
&& after_class.is_japanese_text())
}
/// A question or exclamation mark that ends a sentence takes a one-em space
/// (JLREQ §3.1.6), as in vertical layout.
fn takes_em_space_after(
(_, before_class): (char, JlreqClass),
(_, after_class): (char, JlreqClass),
) -> bool {
before_class == JlreqClass::DividingPunctuation
&& pair_rule(before_class, after_class).preferred_em >= 1.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start};
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider,
};
use skia_safe::FontMgr;
const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
fn strings(texts: &[&str]) -> Vec<String> {
texts.iter().map(|t| t.to_string()).collect()
}
fn apply(texts: &[&str]) -> (Vec<String>, OffsetMap) {
apply_to_span_texts_with_ruby_breaks(&strings(texts), &[])
.expect("expected kinsoku insertions")
}
// -----------------------------------------------------------------
// Insertion
// -----------------------------------------------------------------
#[test]
fn western_word_space_uses_one_third_em_character() {
let (texts, map) = apply(&["hello world"]);
assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]);
assert!(
map.inserted.is_empty(),
"substitution does not shift offsets"
);
}
#[test]
fn no_insertion_for_plain_cjk_text() {
assert!(
apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none()
);
}
#[test]
fn inserts_before_forbidden_start_char() {
let (texts, map) = apply(&["雪国。"]);
assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]);
assert_eq!(map.inserted, vec![2]);
}
#[test]
fn inserts_after_forbidden_end_char() {
let (texts, _) = apply(&["「雪"]);
assert_eq!(texts, vec!["「\u{2060}雪".to_string()]);
}
#[test]
fn no_insertion_at_paragraph_start() {
// A leading start-forbidden char has no break before it to suppress.
let (texts, _) = apply(&["。あ。"]);
assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]);
}
#[test]
fn insertion_spans_boundary() {
// The pair (end of span 0, start of span 1) is evaluated; the
// joiner lands at the head of span 1.
let (texts, _) = apply(&["雪国", "。です"]);
assert_eq!(
texts,
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
);
}
#[test]
fn inserts_quarter_em_at_japanese_western_boundaries() {
let (texts, map) = apply(&["日本Penpot版"]);
assert_eq!(
texts,
vec![format!(
"日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版"
)]
);
assert_eq!(map.inserted, vec![2, 9]);
assert_eq!(map.to_original(map.to_shifted(2)), 2);
assert_eq!(map.to_original(map.to_shifted(8)), 8);
}
#[test]
fn japanese_western_spacing_crosses_span_boundary() {
let (texts, map) = apply(&["日本", "Penpot"]);
assert_eq!(
texts,
vec![
"日本".to_string(),
format!("{JAPANESE_WESTERN_SPACE}Penpot")
]
);
assert_eq!(map.inserted, vec![2]);
}
#[test]
fn no_insertion_inside_a_variation_sequence() {
assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["葛\u{E0100}城"]), &[]).is_none());
}
#[test]
fn no_insertion_before_halfwidth_voiced_marks() {
assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["ガギ"]), &[]).is_none());
}
#[test]
fn no_insertion_inside_an_emoji_zwj_sequence() {
assert!(
apply_to_span_texts_with_ruby_breaks(&strings(&["👩\u{200D}💻\u{200D}あ"]), &[])
.is_none()
);
}
#[test]
fn grapheme_extenders_keep_the_base_as_previous_character() {
let (texts, _) = apply(&["葛\u{E0100}Penpot"]);
assert_eq!(
texts,
vec![format!("葛\u{E0100}{JAPANESE_WESTERN_SPACE}Penpot")]
);
}
#[test]
fn ruby_joiners_stay_outside_grapheme_clusters() {
let texts = strings(&["葛\u{E0100}城"]);
let (shifted, _) = apply_to_span_texts_with_ruby_breaks(&texts, &[Some(Vec::new())])
.expect("expected a ruby joiner");
assert_eq!(shifted, vec!["葛\u{E0100}\u{2060}城".to_string()]);
}
#[test]
fn no_japanese_western_space_beside_symbols() {
assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["あ😀い"]), &[]).is_none());
assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["(注)あ"]), &[]).is_none());
}
#[test]
fn full_width_digits_stay_together() {
let (texts, _) = apply(&["2026年"]);
assert_eq!(
texts,
vec!["2\u{2060}0\u{2060}2\u{2060}6年".to_string()]
);
}
#[test]
fn numerals_keep_their_unit() {
let (texts, _) = apply(&["体重60㎏"]);
assert_eq!(
texts,
vec![format!("体重{JAPANESE_WESTERN_SPACE}60\u{2060}㎏")]
);
}
#[test]
fn prefixed_signs_bind_only_to_numerals() {
let (texts, _) = apply(&["C# は"]);
assert_eq!(texts, vec![format!("C#{WESTERN_WORD_SPACE}は")]);
}
#[test]
fn a_line_may_start_with_an_inseparable_mark() {
// UAX #14 forbids the break before …; the zero-width space reopens it.
let (texts, _) = apply(&["あ…"]);
assert_eq!(texts, vec!["あ\u{200B}…".to_string()]);
let (texts, _) = apply(&["あ……"]);
assert_eq!(texts, vec!["あ\u{200B}…\u{2060}…".to_string()]);
let (texts, _) = apply(&["「……"]);
assert_eq!(texts, vec!["「\u{2060}…\u{2060}…".to_string()]);
}
#[test]
fn japanese_western_space_follows_long_vowels_and_small_kana() {
let (texts, _) = apply(&["ユーザーID"]);
assert_eq!(
texts,
vec![format!(
"ユ\u{2060}ーザ\u{2060}ー{JAPANESE_WESTERN_SPACE}ID"
)]
);
}
#[test]
fn japanese_western_space_keeps_a_forbidden_break_blocked() {
let (texts, _) = apply(&["Excelっぽい"]);
assert_eq!(
texts,
vec![format!("Excel{JAPANESE_WESTERN_SPACE}\u{2060}っぽい")]
);
}
#[test]
fn dividing_punctuation_takes_an_em_space_before_text() {
let (texts, _) = apply(&["え?はい"]);
assert_eq!(texts, vec![format!("え\u{2060}?{IDEOGRAPHIC_SPACE}はい")]);
}
#[test]
fn dividing_punctuation_adds_no_space_in_a_sequence_or_before_a_typed_space() {
let (texts, _) = apply(&["え!?はい"]);
assert_eq!(
texts,
vec![format!("え\u{2060}!\u{2060}?{IDEOGRAPHIC_SPACE}はい")]
);
let (texts, _) = apply(&["え? はい"]);
assert_eq!(texts, vec!["え\u{2060}? はい".to_string()]);
let (texts, _) = apply(&["え?」"]);
assert_eq!(texts, vec!["え\u{2060}?\u{2060}」".to_string()]);
}
#[test]
fn offset_map_lookups_match_a_linear_scan() {
let linear_to_shifted = |inserted: &[usize], original: usize| {
let mut shifted = original;
for &p in inserted {
if p <= shifted {
shifted += 1;
} else {
break;
}
}
shifted
};
for inserted in [
vec![],
vec![0],
vec![2, 3, 4],
vec![1, 5, 6, 10],
vec![0, 1, 2],
] {
let map = OffsetMap {
inserted: inserted.clone(),
};
for offset in 0..16 {
assert_eq!(
map.to_shifted(offset),
linear_to_shifted(&inserted, offset),
"to_shifted({offset}) for {inserted:?}"
);
assert_eq!(
map.to_original(offset),
offset - inserted.iter().take_while(|&&p| p < offset).count(),
"to_original({offset}) for {inserted:?}"
);
}
}
}
#[test]
fn consecutive_forbidden_chars() {
let (texts, map) = apply(&["た」。"]);
assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]);
assert_eq!(map.inserted, vec![1, 3]);
}
#[test]
fn small_kana_and_prolonged_sound() {
let (texts, _) = apply(&["コーヒー"]);
assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]);
let (texts, _) = apply(&["ちょっと"]);
assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]);
}
// -----------------------------------------------------------------
// Offset map
// -----------------------------------------------------------------
#[test]
fn offset_map_round_trips_every_original_index() {
let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。";
let (_, map) = apply(&[original]);
let len = original.encode_utf16().count();
for i in 0..=len {
assert_eq!(
map.to_original(map.to_shifted(i)),
i,
"round-trip failed at original index {i}"
);
}
}
#[test]
fn offset_map_recovers_original_characters() {
let original = "た」。あ";
let (texts, map) = apply(&[original]);
let shifted = texts.concat();
let shifted_units: Vec<u16> = shifted.encode_utf16().collect();
let original_units: Vec<u16> = original.encode_utf16().collect();
for (s_idx, unit) in shifted_units.iter().enumerate() {
if *unit == WORD_JOINER as u16 {
continue;
}
assert_eq!(original_units[map.to_original(s_idx)], *unit);
}
}
#[test]
fn offset_map_on_joiner_resolves_to_boundary() {
// "雪国。" → joiner at shifted 2; both sides of the joiner map
// to original boundary 2.
let (_, map) = apply(&["雪国。"]);
assert_eq!(map.to_original(2), 2);
assert_eq!(map.to_original(3), 2);
assert_eq!(map.to_shifted(2), 3);
}
// -----------------------------------------------------------------
// Real skparagraph layout
// -----------------------------------------------------------------
fn font_collection() -> FontCollection {
let font_mgr = FontMgr::new();
let typeface = font_mgr
.new_from_data(skia_safe::Data::new_copy(TEST_FONT), None)
.expect("failed to load test font");
let mut provider = TypefaceFontProvider::new();
provider.register_typeface(typeface, Some("TestFont"));
let mut collection = FontCollection::new();
collection.set_asset_font_manager(Some(provider.into()));
collection.set_default_font_manager(FontMgr::new(), None);
collection
}
/// Lays out `text` at `width` and returns each line as a UTF-16
/// (start, end) range.
fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> {
let collection = font_collection();
let paragraph_style = ParagraphStyle::default();
let mut builder = ParagraphBuilder::new(&paragraph_style, collection);
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
style.set_letter_spacing(letter_spacing);
builder.push_style(&style);
builder.add_text(text);
let mut paragraph = builder.build();
paragraph.layout(width);
paragraph
.get_line_metrics()
.iter()
.map(|line| (line.start_index, line.end_index))
.collect()
}
#[test]
fn layout_breaks_before_an_ellipsis() {
// JLREQ Fig 81: という……そういう breaks as という|……そういう; only the
// two dots of …… stay together.
let (texts, map) = apply(&["という……そういう"]);
let shifted = texts.concat();
assert_eq!(shifted.matches(BREAK_OPPORTUNITY).count(), 1);
assert!(shifted.contains("う\u{200B}…\u{2060}…"));
let lines = layout_lines(&shifted, 20.0 * 4.0 + 1.0, 0.0);
let second = map.to_original(lines[1].0);
assert_eq!(second, 3, "the second line starts at ……: {lines:?}");
}
fn utf16_chars(text: &str) -> Vec<char> {
// BMP-only fixtures: one char per UTF-16 unit.
text.chars().collect()
}
/// Asserts no line starts with a start-forbidden char nor ends with
/// an end-forbidden char, in ORIGINAL text coordinates.
fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
let chars = utf16_chars(original);
for (i, (start, end)) in lines.iter().enumerate() {
let orig_start = map.to_original(*start);
let orig_end = map.to_original(*end);
if i > 0 {
let first = chars[orig_start];
assert!(
!forbidden_at_line_start(first),
"line {i} starts with forbidden char {first} (text {original})"
);
}
if i < lines.len() - 1 && orig_end > orig_start {
// The last char actually rendered on the line (end may
// include trailing whitespace-like positions).
let last = chars[orig_end - 1];
assert!(
!forbidden_at_line_end(last),
"line {i} ends with forbidden char {last} (text {original})"
);
}
}
}
/// Lines mapped back to original offsets must tile the original
/// text exactly: contiguous, in order, full coverage.
fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
let mut expected_start = 0;
for (start, end) in lines {
let orig_start = map.to_original(*start);
let orig_end = map.to_original(*end);
assert_eq!(orig_start, expected_start, "line ranges must be contiguous");
expected_start = orig_end;
}
assert_eq!(expected_start, original.encode_utf16().count());
}
fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) {
let (texts, map) = apply(&[original]);
let lines = layout_lines(&texts.concat(), width, 0.0);
(lines, map)
}
fn measure_width(text: &str) -> f32 {
let collection = font_collection();
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection);
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
builder.push_style(&style);
builder.add_text(text);
let mut p = builder.build();
p.layout(f32::MAX);
p.longest_line()
}
#[test]
fn joiner_is_zero_width_in_layout() {
let plain = layout_lines("国国", f32::MAX, 0.0);
let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0);
assert_eq!(plain.len(), 1);
assert_eq!(joined.len(), 1);
let collection = font_collection();
let measure = |text: &str| {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
builder.push_style(&style);
builder.add_text(text);
let mut p = builder.build();
p.layout(f32::MAX);
p.longest_line()
};
let diff = (measure("国国") - measure("国\u{2060}国")).abs();
assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}");
}
#[test]
fn suppresses_breaks_on_kinsoku_fixtures() {
let fixtures = [
// kinsoku-line-start
"これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
// kinsoku-line-end
"開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
// prolonged-sound
"コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
// small-kana-sokuon
"ちょっと待ってください。キャッシュとクッキーをチェックする。",
// long-paragraph-wrap
"国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
];
// Vary the width to move the breaks. Each width must exceed the
// longest joined run, or skparagraph falls back to an emergency
// mid-run break.
let char_width = measure_width("国");
for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) {
for original in fixtures {
let (lines, map) = fixture_lines(original, width);
assert!(lines.len() > 1, "fixture must wrap at width {width}");
assert_kinsoku_clean(original, &lines, &map);
assert_lines_tile_original(original, &lines, &map);
}
}
}
#[test]
fn mixed_latin_cjk_fixture() {
let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!";
let char_width = measure_width("国");
for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) {
let (lines, map) = fixture_lines(original, width);
assert_kinsoku_clean(original, &lines, &map);
assert_lines_tile_original(original, &lines, &map);
}
}
#[test]
fn joiner_becomes_visible_under_letter_spacing() {
// skparagraph applies letter-spacing per cluster, INCLUDING the
// zero-width joiner, which would double the tracking at every
// suppressed break, so the horizontal builder gives inserted
// joiners zero letter-spacing. If this test fails (Skia stops
// spacing ignorables), that special style can go.
let collection = font_collection();
let measure = |text: &str| {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
style.set_letter_spacing(5.0);
builder.push_style(&style);
builder.add_text(text);
let mut p = builder.build();
p.layout(f32::MAX);
p.longest_line()
};
let diff = (measure("国\u{2060}国") - measure("国国")).abs();
assert!(
diff > 0.01,
"letter-spacing no longer affects the joiner; the joiner \
style in add_text_with_sheds can be removed"
);
}
}