diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index 5f944ef288..060f06d932 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -43,6 +43,23 @@ Syllable mode may then expand eligible character gaps by at most one eighth of an em. Word mode never expands inside a word. Original spaces remain in source text; no Japanese word-space substitution applies. +## Japanese line adjustment + +Japanese justification uses the JLREQ §3.8.4 ordered expansion in both +orientations (`jlreq_ordered_expansion`). Gaps widen by pair-table stage: +Western word spaces, then Japanese/Western gaps up to half an em, then +solid kana and kanji up to a quarter em. Slack left after the three stages +spreads evenly over the same gaps. Punctuation aki, gaps beside a ruby base, +gaps inside a Western word and gaps across a warichu note take none. The +final line or column stays unexpanded; distribution keeps its own path. + +Squeezing (oikomi) follows the line adjustment setting in both orientations. +Push-in first squeezes whenever the aki on the line can hold the next unit. +Push-out first squeezes only when pushing out cannot resolve a kinsoku +break, which horizontally means the break would move back over more than +four kinsoku pairs. Horizontal text keeps a unit wider than the box whole +on its own line instead. Push-out only never squeezes. + ## Horizontal emergency wrapping and offsets SkParagraph can ignore word joins during emergency wrapping. The horizontal diff --git a/render-wasm/src/shapes/jlreq.rs b/render-wasm/src/shapes/jlreq.rs index 9578a0767d..2300f143d5 100644 --- a/render-wasm/src/shapes/jlreq.rs +++ b/render-wasm/src/shapes/jlreq.rs @@ -507,6 +507,15 @@ pub fn forbidden_at_line_end(c: char) -> bool { classify(c).forbids_line_end() } +/// Most kinsoku pairs oidashi moves a break back over. Past that, pushing +/// out does not resolve the break, and the line may squeeze instead. +pub const MAX_KINSOKU_SHIFT: usize = 4; + +/// True when kinsoku forbids a break between `before` and `after`. +pub fn kinsoku_blocks(before: char, after: char) -> bool { + forbidden_at_line_end(before) || forbidden_at_line_start(after) +} + /// Whether a line may break between two adjacent characters: the pair table /// (kinsoku, Western and numeral runs) plus `keeps_together`. pub fn break_allowed_between(before: char, after: char) -> bool { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 67c94ca6c3..65f695b4ac 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -686,12 +686,25 @@ impl TextContent { return true; } (uses_japanese_layout(paragraph, &plan.texts) - && (self.line_adjustment == LineAdjustment::PushInFirst - || paragraph.text_align() == TextAlign::Justify)) + && (paragraph.text_align() == TextAlign::Justify + || self.squeezes_lines(paragraph, plan))) || (paragraph.layout_policy().bounded_justify() && paragraph.text_align() == TextAlign::Justify) } + /// True when line adjustment may squeeze lines of a horizontal Japanese + /// paragraph: always under push-in first; under push-out first only + /// when some kinsoku run is too long for push-out to resolve, as in + /// vertical columns; never under push-out only. + fn squeezes_lines(&self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> bool { + paragraph.layout_policy().uses_jlreq() + && match self.line_adjustment { + LineAdjustment::PushInFirst => true, + LineAdjustment::PushOutFirst => horizontal_push_out_may_fail(paragraph, plan), + LineAdjustment::PushOutOnly => false, + } + } + /// Squeeze sheds and justify spacing of each horizontal paragraph at /// the layout width, cached until the content or the width /// changes. `None` when no paragraph needs them. @@ -749,10 +762,8 @@ impl TextContent { laid_out.layout(plan.layout_width(width)); laid_out }; - let (sheds, laid_out) = if paragraph.layout_policy().uses_jlreq() - && self.line_adjustment == LineAdjustment::PushInFirst - { - horizontal_squeeze_sheds(paragraph, plan, width, &lay_out) + let (sheds, laid_out) = if self.squeezes_lines(paragraph, plan) { + horizontal_squeeze_sheds(paragraph, plan, width, self.line_adjustment, &lay_out) } else { let sheds = vec![Vec::new(); plan.texts.len()]; let laid_out = lay_out(&sheds, FULL_WINDOW); @@ -762,7 +773,7 @@ impl TextContent { horizontal_pairwise_justify_spacing(paragraph, &laid_out, plan, width) } else if paragraph.text_align() == TextAlign::Justify { if paragraph.layout_policy().uses_jlreq() { - horizontal_jlreq_justify_spacing(&laid_out, plan, width) + horizontal_jlreq_justify_spacing(paragraph, &laid_out, plan, width) } else { horizontal_pairwise_justify_spacing(paragraph, &laid_out, plan, width) } diff --git a/render-wasm/src/shapes/text_allocation.rs b/render-wasm/src/shapes/text_allocation.rs index 454ce8d9b5..09392ee320 100644 --- a/render-wasm/src/shapes/text_allocation.rs +++ b/render-wasm/src/shapes/text_allocation.rs @@ -1,6 +1,7 @@ //! Equal division of an adjustment over capped opportunities, shared by the //! horizontal and vertical justification and squeezing passes. +use crate::shapes::jlreq::{pair_rule, JlreqClass}; use crate::shapes::text_policy::TextLayoutPolicy; /// Amounts below this count as zero when dividing adjustments. @@ -82,3 +83,82 @@ pub(crate) fn allocate_bounded_expansion( } allocations } + +/// Priority and pixel cap of the JLREQ justification gap between classes +/// `before` and `after`, which already hold `spacing_em` of pair spacing, at +/// the smaller adjacent font size `em`; `None` when the pair table gives the +/// gap no expansion. Both axes classify their gaps with this. +pub(crate) fn jlreq_expansion_gap( + before: JlreqClass, + after: JlreqClass, + spacing_em: f32, + em: f32, +) -> Option<(u8, f32)> { + let rule = pair_rule(before, after); + (rule.expand_priority > 0).then(|| { + ( + rule.expand_priority, + (rule.maximum_em - spacing_em).max(0.0) * em, + ) + }) +} + +/// JLREQ ordered expansion (§3.8.4) of one line or column's `slack` over +/// its `gaps` (index, priority, cap in px) from `jlreq_expansion_gap`: +/// stages 1–3 widen gaps in priority order, equally within a stage and up +/// to each cap; a fourth stage spreads what is left evenly over every gap. +/// Returns (index, amount) per allocation; an index may repeat. +pub(crate) fn jlreq_ordered_expansion(gaps: &[(usize, u8, f32)], slack: f32) -> Vec<(usize, f32)> { + let mut remaining = slack.max(0.0); + let mut allocations = Vec::new(); + for priority in 1..=3 { + let caps: Vec<(usize, f32)> = gaps + .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; + } + } + if remaining > ALLOCATION_EPSILON && !gaps.is_empty() { + let extra = remaining / gaps.len() as f32; + allocations.extend(gaps.iter().map(|(index, _, _)| (*index, extra))); + } + allocations +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordered_expansion_fills_each_stage_before_the_next() { + // Inter-script (stage 2, half an em) before solid kana (stage 3). + let script = jlreq_expansion_gap(JlreqClass::Hiragana, JlreqClass::Western, 0.25, 20.0); + let kana = jlreq_expansion_gap(JlreqClass::Hiragana, JlreqClass::Hiragana, 0.0, 20.0); + assert_eq!(script, Some((2, 5.0))); + assert_eq!(kana, Some((3, 5.0))); + assert_eq!( + jlreq_expansion_gap(JlreqClass::Comma, JlreqClass::Hiragana, 0.5, 20.0), + None, + "punctuation aki takes no justification" + ); + let gaps = [(0, 3, 5.0), (1, 2, 5.0), (2, 3, 5.0)]; + let total = |allocations: &[(usize, f32)], index: usize| -> f32 { + allocations + .iter() + .filter(|(at, _)| *at == index) + .map(|(_, amount)| amount) + .sum() + }; + let small = jlreq_ordered_expansion(&gaps, 4.0); + assert_eq!((total(&small, 0), total(&small, 1)), (0.0, 4.0)); + let medium = jlreq_ordered_expansion(&gaps, 9.0); + assert_eq!((total(&medium, 0), total(&medium, 1)), (2.0, 5.0)); + let large = jlreq_ordered_expansion(&gaps, 18.0); + assert!((total(&large, 0) - 6.0).abs() < 0.001); + assert!((total(&large, 1) - 6.0).abs() < 0.001); + } +} diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs index a362a1946e..9cfa04589e 100644 --- a/render-wasm/src/shapes/text_horizontal/justify.rs +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -1,54 +1,107 @@ // Line adjustment of horizontal CJK text. SkParagraph only expands around // ideographs, which bunches the space next to kanji. Japanese justification -// (JLREQ §3.8) spreads each line's slack evenly between its characters, and -// Japanese squeezing (oikomi) reduces punctuation aki to keep the next -// character. The other policies justify over their pairwise expansion -// opportunities with bounded amounts. +// (JLREQ §3.8) widens each line's gaps in the pair-table order vertical +// columns use, and Japanese squeezing (oikomi) reduces punctuation aki to +// keep the next character. The other policies justify over their pairwise +// expansion opportunities with bounded amounts. +use crate::shapes::jlreq::{classify, kinsoku_blocks, MAX_KINSOKU_SHIFT}; use crate::shapes::text_allocation::{ - allocate_bounded_expansion, bounded_expansion, capped_equal_allocations, + allocate_bounded_expansion, bounded_expansion, capped_equal_allocations, jlreq_expansion_gap, + jlreq_ordered_expansion, }; use crate::shapes::text_paragraph::LayoutParagraph; +use crate::shapes::LineAdjustment; use super::*; /// Slack kept back from each justified line so float noise never wraps it. const JUSTIFY_EPSILON: f32 = 0.05; +/// How far a character may pass the line end and still count as fitting. +const FIT_TOLERANCE: f32 = 0.01; + /// True for a character that belongs to a Western word: no space opens /// between two of them. fn is_word_char(ch: char) -> bool { !ch.is_whitespace() && !is_cjk_text_char(ch) && !line_breaks::is_inserted_zero_width(ch) } +/// One real character of a horizontal Japanese paragraph for justification. +struct JustifyChar { + /// UTF-16 offset in the builder text. + utf16: usize, + span: usize, + /// Char index in the span's layout text. + index: usize, + /// Pair-table class: ruby bases count as simple ruby, as vertically. + class: JlreqClass, + font_size: f32, + /// A warichu note sits between it and the previous character. + after_note: bool, +} + +/// The characters justification spaces, in builder order. Characters the +/// layout pass inserted (word joiners, break opportunities and the spaces +/// that set the preferred inter-script and question-mark gaps) belong to +/// the gap between their neighbours, not to a gap of their own. +fn justify_chars(paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> Vec { + let spans = paragraph.children(); + let offset_map = &plan.offsets.offset_map; + let mut chars = Vec::new(); + let mut after_note = false; + for range in &plan.offsets.ranges { + if range.warichu { + after_note = true; + continue; + } + let (Some(span), Some(text)) = (spans.get(range.span), plan.texts.get(range.span)) else { + continue; + }; + let mut utf16 = range.builder_start; + for (index, ch) in text.chars().enumerate() { + let shifted = range.shifted_start + (utf16 - range.builder_start); + if !line_breaks::is_inserted_zero_width(ch) && !offset_map.is_inserted(shifted) { + let class = if span.has_ruby() { + JlreqClass::SimpleRuby + } else if ch == line_breaks::WESTERN_WORD_SPACE { + JlreqClass::WesternWordSpace + } else { + classify(ch) + }; + chars.push(JustifyChar { + utf16, + span: range.span, + index, + class, + font_size: span.font_size, + after_note, + }); + after_note = false; + } + utf16 += ch.len_utf16(); + } + } + chars +} + /// Per-span letter-spacing (char index in the layout text, amount) that /// justifies `laid_out`, the paragraph of `plan` laid out unjustified at -/// `width`. Every line but the last spreads its slack evenly over the gaps -/// between its characters, skipping gaps inside a Western word and the -/// zero-width word joiners; warichu notes take none. +/// `width`. Every line but the last takes the JLREQ ordered expansion that +/// vertical columns use (`jlreq_ordered_expansion`): gaps widen in pair-table +/// priority, Western word spaces, then Japanese/Western gaps, then solid +/// kana and kanji, and any slack left spreads over all of them. Gaps inside +/// a Western word, beside a ruby base, around punctuation aki or across a +/// warichu note take none; neither do inserted joiners. Each amount lands +/// after the character before its gap. pub(crate) fn horizontal_jlreq_justify_spacing( + paragraph: &Paragraph, laid_out: &LayoutParagraph, plan: &HorizontalParagraphPlan, width: f32, ) -> Vec> { let mut spacing: Vec> = vec![Vec::new(); plan.texts.len()]; - // (builder UTF-16 offset, span, char index, char) of every character. - let mut chars: Vec<(usize, usize, usize, char)> = Vec::new(); - for range in &plan.offsets.ranges { - if range.warichu { - continue; - } - let Some(text) = plan.texts.get(range.span) else { - continue; - }; - let mut utf16 = range.builder_start; - for (index, ch) in text.chars().enumerate() { - if !line_breaks::is_inserted_zero_width(ch) { - chars.push((utf16, range.span, index, ch)); - } - utf16 += ch.len_utf16(); - } - } + let chars = justify_chars(paragraph, plan); let lines = laid_out.get_line_metrics(); let Some((_, justified)) = lines.split_last() else { return spacing; @@ -58,23 +111,30 @@ pub(crate) fn horizontal_jlreq_justify_spacing( if slack <= 0.0 { continue; } - let in_line: Vec<&(usize, usize, usize, char)> = chars - .iter() - .filter(|(utf16, ..)| { - *utf16 >= line.start_index && *utf16 < line.end_excluding_whitespaces + let start = chars.partition_point(|c| c.utf16 < line.start_index); + let end = chars.partition_point(|c| c.utf16 < line.end_excluding_whitespaces); + // (char before the gap, priority, cap) + let gaps: Vec<(usize, u8, f32)> = (start..end.saturating_sub(1)) + .filter(|at| !chars[at + 1].after_note) + .filter_map(|at| { + let (before, after) = (&chars[at], &chars[at + 1]); + let em = before.font_size.min(after.font_size); + let spacing_em = pair_rule(before.class, after.class).preferred_em; + jlreq_expansion_gap(before.class, after.class, spacing_em, em) + .map(|(priority, cap)| (at, priority, cap)) }) .collect(); - let gaps: Vec<&(usize, usize, usize, char)> = in_line - .windows(2) - .filter(|pair| !(is_word_char(pair[0].3) && is_word_char(pair[1].3))) - .map(|pair| pair[0]) - .collect(); - if gaps.is_empty() { - continue; + let mut amounts: Vec<(usize, f32)> = Vec::new(); + for (at, amount) in jlreq_ordered_expansion(&gaps, slack) { + match amounts.iter_mut().find(|(index, _)| *index == at) { + Some((_, total)) => *total += amount, + None => amounts.push((at, amount)), + } } - let share = slack / gaps.len() as f32; - for (_, span, index, _) in gaps { - spacing[*span].push((*index, share)); + for (at, amount) in amounts { + if amount > 0.0 { + spacing[chars[at].span].push((chars[at].index, amount)); + } } } spacing @@ -254,20 +314,84 @@ fn squeeze_capacities(chars: &[LineChar], range: std::ops::Range) -> Vec< .collect() } +/// The trailing aki a closing bracket or comma sheds at a line end. +fn line_end_aki(ch: &LineChar) -> f32 { + match ch.class { + Some(JlreqClass::ClosingBracket | JlreqClass::Comma) => ch.font_size / 2.0, + _ => 0.0, + } +} + +/// True when some line of the paragraph could squeeze under +/// `LineAdjustment::PushOutFirst`: only a run of more kinsoku pairs than +/// oidashi moves over (`MAX_KINSOKU_SHIFT`) leaves push-out unresolved. +pub(crate) fn horizontal_push_out_may_fail( + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, +) -> bool { + let chars = line_chars(paragraph, plan); + let mut run = 0usize; + chars.windows(2).any(|pair| { + run = if kinsoku_blocks(pair[0].ch, pair[1].ch) { + run + 1 + } else { + 0 + }; + run > MAX_KINSOKU_SHIFT + }) +} + +/// Whether SkParagraph's push-out of `unit`, the characters that start the +/// next line, is one vertical oidashi accepts (`push_out_resolves`). Without +/// kinsoku the line would break before the first unit character that does +/// not fit `room`, the space the line leaves; moving that break back to the +/// unit start must cross at most `MAX_KINSOKU_SHIFT` kinsoku pairs. Lines +/// never start inside the unit, since joiners keep it whole, and a unit +/// wider than the box overflows on its own line, so the shift limit is the +/// only way push-out fails here. `widths` holds each unit character's width. +fn push_out_resolves( + chars: &[LineChar], + unit: std::ops::Range, + widths: &[f32], + room: f32, +) -> bool { + let mut used = 0.0; + let mut natural = unit.end; + for (at, width) in unit.clone().zip(widths) { + if used + width - line_end_aki(&chars[at]) > room + FIT_TOLERANCE { + natural = at; + break; + } + used += width; + } + let shifts = (unit.start + 1..=natural.min(unit.end - 1)) + .filter(|at| kinsoku_blocks(chars[at - 1].ch, chars[*at].ch)) + .count(); + natural == unit.start || shifts <= MAX_KINSOKU_SHIFT +} + /// Per-span sheds (char index, amount) that squeeze lines of a horizontal /// Japanese paragraph to keep the character that would start the next line /// (JLREQ §3.8 oikomi). Each line, in order, takes the unit starting the next /// line when the pair table's squeezable aki on the line covers the /// overflow, reduced in priority order and equally within a priority. A /// trailing closing bracket or comma pulled to the line end sheds its aki -/// there. `lay_out` lays the paragraph out with extra sheds; returns the -/// sheds and the final layout. +/// there. Under `PushInFirst` every line may squeeze; under `PushOutFirst` +/// only a line whose push-out does not resolve kinsoku (`push_out_resolves`), +/// as vertical columns do; `PushOutOnly` never squeezes. `lay_out` lays the +/// paragraph out with extra sheds; returns the sheds and the final layout. pub(crate) fn horizontal_squeeze_sheds( paragraph: &Paragraph, plan: &HorizontalParagraphPlan, width: f32, + adjustment: LineAdjustment, lay_out: &LayOutWithSheds, ) -> (Vec>, LayoutParagraph) { + if adjustment == LineAdjustment::PushOutOnly { + let sheds = vec![Vec::new(); plan.texts.len()]; + let laid_out = lay_out(&sheds, FULL_WINDOW); + return (sheds, laid_out); + } let chars = line_chars(paragraph, plan); let mut sheds: Vec> = vec![Vec::new(); plan.texts.len()]; let total = plan @@ -342,11 +466,29 @@ pub(crate) fn horizontal_squeeze_sheds( .map(|rect| rect.rect.width()) .sum::(); let last = &chars[unit.end - 1]; - let trailing_aki = match last.class { - Some(JlreqClass::ClosingBracket | JlreqClass::Comma) => last.font_size / 2.0, - _ => 0.0, - }; + let trailing_aki = line_end_aki(last); let needed = line.width as f32 + unit_width - trailing_aki - width + JUSTIFY_EPSILON; + if needed > 0.0 && adjustment == LineAdjustment::PushOutFirst { + let widths: Vec = unit + .clone() + .map(|at| { + let end = chars.get(at + 1).map_or(unit_end_utf16, |c| c.utf16); + laid_out + .get_rects_for_range( + chars[at].utf16 - window_start..end - window_start, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .iter() + .map(|rect| rect.rect.width()) + .sum::() + }) + .collect(); + if push_out_resolves(&chars, unit.clone(), &widths, width - line.width as f32) { + line_index += 1; + continue; + } + } let line_chars_end = unit.start; let line_chars_start = chars .iter() @@ -550,7 +692,13 @@ mod tests { laid_out.layout(width); laid_out }; - let (windowed, _) = horizontal_squeeze_sheds(¶graph, &plan, width, &lay_out); + let (windowed, _) = horizontal_squeeze_sheds( + ¶graph, + &plan, + width, + LineAdjustment::PushInFirst, + &lay_out, + ); assert_eq!( full_layouts.get(), 1, diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 6228b1bc9a..fa014da994 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -6,7 +6,7 @@ use super::ruby::{ use super::*; use crate::shapes::text_paragraph::LayoutParagraph; use crate::shapes::text_policy::TextLayoutPolicy; -use crate::shapes::{TextAlign, TextDirection, TextTransform}; +use crate::shapes::{LineAdjustment, TextAlign, TextDirection, TextTransform}; use crate::Uuid; fn cjk_horizontal_layout( @@ -1730,7 +1730,7 @@ fn horizontal_justify_spreads_every_line_evenly() { }; let natural = lay_out(&[]); - let spacing = horizontal_jlreq_justify_spacing(&natural, &plan, width); + let spacing = horizontal_jlreq_justify_spacing(¶graph, &natural, &plan, width); let justified = lay_out(&spacing[0]); let natural_lines = natural.get_line_metrics(); @@ -1817,7 +1817,13 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() { builder_text[natural_end], 'か', "the natural line ends before か" ); - let (sheds, squeezed) = horizontal_squeeze_sheds(¶graph, &plan, width, &lay_out); + let (sheds, squeezed) = horizontal_squeeze_sheds( + ¶graph, + &plan, + width, + crate::shapes::LineAdjustment::PushInFirst, + &lay_out, + ); assert_eq!( squeezed.get_line_metrics()[0].end_index, @@ -2548,3 +2554,257 @@ fn horizontal_emphasis_mark_box_is_the_painted_mark_on_either_side() { ); } } + +/// Gaps, in em, of the first line of ああAああPenpot justified horizontally +/// with `extra_em` of slack on that line: (ああ, あA, Aあ, ああ). +fn horizontal_justified_gaps(extra_em: f32) -> Vec { + use crate::shapes::text_vertical::test_support::{ + provider_with_fallback, EM, TEST_FONT, VPAL_TEST_FONT, + }; + init_state(); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_asset_font_manager(Some( + provider_with_fallback(VPAL_TEST_FONT, TEST_FONT).into(), + )); + let span = TextSpan { + font_size: EM, + ..make_span("ああAああPenpot", 0.0) + }; + let paragraph = make_paragraph(vec![span], 0.0); + let (plan, wide) = cjk_layout_paragraph(¶graph, 1000.0, &fonts); + // Builder offset after the fifth real character, past both inserted + // inter-script spaces. + let texts: Vec = plan.texts[0].chars().collect(); + let fifth = texts + .iter() + .enumerate() + .filter(|(_, ch)| **ch != line_breaks::JAPANESE_WESTERN_SPACE) + .nth(4) + .map(|(index, _)| index) + .unwrap(); + let five = + wide.get_rects_for_range(0..fifth + 1, RectHeightStyle::Tight, RectWidthStyle::Tight); + let five_width = five.last().unwrap().rect.right() - five[0].rect.left(); + let width = five_width + extra_em * EM; + let (plan, natural) = cjk_layout_paragraph(¶graph, width, &fonts); + let lines = natural.get_line_metrics(); + assert_eq!(lines.len(), 2, "Penpot moves to the second line"); + let spacing = horizontal_jlreq_justify_spacing(¶graph, &natural, &plan, width); + let at = |index: usize| -> f32 { + spacing[0] + .iter() + .filter(|(at, _)| *at == index) + .map(|(_, amount)| amount) + .sum::() + / EM + }; + // あ0 あ1 U+2005 A3 U+2005 あ5 あ6: each gap takes the spacing of the + // character before it. + assert_eq!(texts[2], line_breaks::JAPANESE_WESTERN_SPACE); + vec![at(0), at(1), at(3), at(5)] +} + +/// The same gaps of ああAああPenpot justified as a vertical column. +fn vertical_justified_gaps(extra_em: f32) -> Vec { + use crate::shapes::text_vertical::test_support::{ + layout_with_fallback, provider_with_fallback, spans_content, vertical_paragraph, EM, + TEST_FONT, VPAL_TEST_FONT, + }; + let provider = provider_with_fallback(VPAL_TEST_FONT, TEST_FONT); + let fallback = ["fallback".to_string()]; + let content = |align: TextAlign, height: f32| { + let span = TextSpan { + text: "ああAああPenpot".to_string(), + font_size: EM, + line_height: 1.0, + ..TextSpan::default() + }; + let mut content = spans_content(vec![], height); + *content.paragraphs_mut() = vec![vertical_paragraph(vec![span], align, 1.0)]; + content + }; + let wide = layout_with_fallback( + &provider, + &content(TextAlign::Left, 1000.0), + 1000.0, + &fallback, + ); + let five: f32 = wide.cells[..5].iter().map(|cell| cell.extent).sum(); + let height = five + extra_em * EM; + let layout = layout_with_fallback( + &provider, + &content(TextAlign::Justify, height), + height, + &fallback, + ); + assert!(layout.cells[..5].iter().all(|cell| cell.column == 0)); + assert!(layout.cells[5..].iter().all(|cell| cell.column == 1)); + (0..4) + .map(|i| { + let (cell, next) = (&layout.cells[i], &layout.cells[i + 1]); + let natural = wide.cells[i + 1].top - wide.cells[i].top; + (next.top - cell.top - natural) / EM + }) + .collect() +} + +#[test] +fn horizontal_and_vertical_justify_widen_gaps_in_the_same_jlreq_order() { + for (axis, gaps) in [ + ( + "horizontal", + horizontal_justified_gaps as fn(f32) -> Vec, + ), + ("vertical", vertical_justified_gaps), + ] { + // Little slack: only the two Japanese/Western gaps widen. + let small = gaps(0.3); + assert!( + small[1] > 0.1 && (small[1] - small[2]).abs() < 0.01, + "{axis}: {small:?}" + ); + assert!( + small[0].abs() < 0.001 && small[3].abs() < 0.001, + "{axis}: {small:?}" + ); + // More slack: they stop at their half-em maximum (a quarter em past + // the preferred quarter), then the kana gaps share the rest. + let large = gaps(0.9); + assert!( + (large[1] - 0.25).abs() < 0.01 && (large[2] - 0.25).abs() < 0.01, + "{axis}: {large:?}" + ); + assert!( + large[0] > 0.1 && (large[0] - large[3]).abs() < 0.01, + "{axis}: {large:?}" + ); + } +} + +/// `paragraph` with the plan's sheds plus `extra`, restricted to the builder +/// `window`, laid out at `width` as the squeeze pass measures it. +fn squeeze_window_layout( + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, + fonts: &skia::textlayout::FontCollection, + width: f32, + extra: &[Vec<(usize, f32)>], + window: std::ops::Range, +) -> LayoutParagraph { + let mut paragraph_style = paragraph.paragraph_to_style(); + paragraph_style.set_apply_rounding_hack(false); + let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + let span = ¶graph.children()[0]; + let style = span.to_style( + &crate::math::Rect::from_xywh(0.0, 0.0, width, 200.0), + &crate::render::FallbackFonts::default(), + false, + 1.0, + ); + let sheds: Vec<(usize, f32)> = plan.pair_spacing[0] + .iter() + .chain(extra.first().into_iter().flatten()) + .copied() + .collect(); + let (text, from) = + window_slice(&plan.texts[0], &plan.offsets.ranges[0], &window).unwrap_or(("", 0)); + let sheds = window_entries(&sheds, from, text.chars().count()); + builder.push_style(&style); + add_horizontal_span(&mut builder, span, text, &sheds, &[], &style, fonts); + let mut laid_out = LayoutParagraph::build(&mut builder); + laid_out.layout(width); + laid_out +} + +/// Squeeze `text` under each line adjustment, at a width that fits the +/// first line up to the `nth` (from zero) `fits` character plus `extra_em`. +/// Returns the line adjustment, whether squeezing kept the next line's first +/// unit and whether the push-out-first gate expects a squeeze. +fn squeeze_outcomes( + text: &str, + (fits, nth): (char, usize), + extra_em: f32, +) -> Vec<(LineAdjustment, bool, bool)> { + use crate::shapes::text_vertical::test_support::{provider, EM, VPAL_TEST_FONT}; + init_state(); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_asset_font_manager(Some(provider(VPAL_TEST_FONT).into())); + let span = TextSpan { + font_size: EM, + ..make_span(text, 0.0) + }; + let paragraph = make_paragraph(vec![span], 0.0); + let plan = HorizontalParagraphPlan::new(¶graph); + let chars: Vec = plan.texts[0].chars().collect(); + let fits_at = chars + .iter() + .enumerate() + .filter(|(_, ch)| **ch == fits) + .nth(nth) + .map(|(at, _)| at) + .unwrap(); + let wide = squeeze_window_layout(¶graph, &plan, &fonts, 1000.0, &[], FULL_WINDOW); + let right = wide.get_rects_for_range( + fits_at..fits_at + 1, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + )[0] + .rect + .right(); + let width = right + extra_em * EM; + let natural = squeeze_window_layout(¶graph, &plan, &fonts, width, &[], FULL_WINDOW); + let natural_end = natural.get_line_metrics()[0].end_index; + assert!( + natural_end <= fits_at, + "the unit at {fits:?} moves down whole" + ); + let may_fail = super::justify::horizontal_push_out_may_fail(¶graph, &plan); + [ + LineAdjustment::PushInFirst, + LineAdjustment::PushOutFirst, + LineAdjustment::PushOutOnly, + ] + .into_iter() + .map(|adjustment| { + let lay_out = |extra: &[Vec<(usize, f32)>], window: std::ops::Range| { + squeeze_window_layout(¶graph, &plan, &fonts, width, extra, window) + }; + let (sheds, squeezed) = + horizontal_squeeze_sheds(¶graph, &plan, width, adjustment, &lay_out); + let kept = squeezed.get_line_metrics()[0].end_index > fits_at; + assert_eq!(kept, !sheds[0].is_empty(), "{adjustment:?}"); + (adjustment, kept, may_fail) + }) + .collect() +} + +#[test] +fn push_out_first_squeezes_when_push_out_leaves_a_closing_bracket_at_the_line_start() { + // Without kinsoku the line would break before the fifth 」. Moving that + // break back to あ crosses five kinsoku pairs, more than oidashi moves + // over, so push-out first squeezes the commas as push-in first does. + let outcomes = squeeze_outcomes("あく、あく、あ」」」」」くあく", ('」', 3), 0.0); + for (adjustment, kept, may_fail) in outcomes { + assert!(may_fail); + assert_eq!( + kept, + adjustment != LineAdjustment::PushOutOnly, + "{adjustment:?}" + ); + } +} + +#[test] +fn push_out_first_keeps_push_out_when_it_resolves_the_line_start() { + // Without kinsoku the line would break before 」; moving あ」 down + // resolves it, so only push-in first squeezes. + let outcomes = squeeze_outcomes("あく、あく、あ」くあく", ('あ', 2), 0.3); + for (adjustment, kept, may_fail) in outcomes { + assert!(!may_fail); + assert_eq!( + kept, + adjustment == LineAdjustment::PushInFirst, + "{adjustment:?}" + ); + } +} diff --git a/render-wasm/src/shapes/text_vertical/flow/mod.rs b/render-wasm/src/shapes/text_vertical/flow/mod.rs index 5daad96bf5..96056f40b8 100644 --- a/render-wasm/src/shapes/text_vertical/flow/mod.rs +++ b/render-wasm/src/shapes/text_vertical/flow/mod.rs @@ -7,8 +7,10 @@ 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::jlreq::{forbidden_at_line_end, forbidden_at_line_start, MAX_KINSOKU_SHIFT}; +use crate::shapes::text_allocation::{ + capped_equal_allocations, jlreq_expansion_gap, jlreq_ordered_expansion, +}; use crate::shapes::{LineAdjustment, TextAlign}; use super::layout::{CellKind, VerticalCell}; @@ -209,8 +211,6 @@ fn column_break( i: usize, max_height: f32, ) -> Option { - const MAX_KINSOKU_SHIFT: usize = 4; - let skip_kept = |mut index: usize| { while index > column_start && items[index].keep_with_previous { index -= 1; @@ -755,9 +755,8 @@ fn at_column_end(placements: &[(usize, f32)], index: usize) -> bool { .is_none_or(|(next_column, _)| *next_column != placements[index].0) } -/// Ordered oidashi expansion for justified columns. Stages 1–3 respect the -/// table caps; stage 4 spreads any remaining slack evenly over every -/// expandable boundary (JLREQ §3.8.4). Returns each cell's extra flow offset. +/// Ordered oidashi expansion for justified columns (JLREQ §3.8.4, see +/// `jlreq_ordered_expansion`). Returns each cell's extra flow offset. /// Skips the last column. pub(super) fn ordered_expansion_offsets( cells: &[FlowCell], @@ -771,40 +770,20 @@ pub(super) fn ordered_expansion_offsets( let mut boundary_expansion = vec![0.0f32; cells.len().saturating_sub(1)]; for (column, used) in column_used.iter().enumerate().take(last_column) { // (boundary, priority, cap) - let mut boundaries: Vec<(usize, u8, f32)> = Vec::new(); - for boundary in 0..cells.len().saturating_sub(1) { - if placements[boundary].0 != column || placements[boundary + 1].0 != column { - continue; - } - let Some((before, after)) = boundary_classes(classes, boundary) else { - continue; - }; - let rule = pair_rule(before, after); - if rule.expand_priority == 0 { - continue; - } - let cap = (rule.maximum_em - pair_spacing_em[boundary]).max(0.0) - * boundary_em(cells, boundary); - boundaries.push((boundary, rule.expand_priority, cap)); - } - let mut remaining = (max_height - used).max(0.0); - for priority in 1..=3 { - let caps: Vec<(usize, f32)> = boundaries - .iter() - .filter(|(_, p, _)| *p == priority) - .map(|(boundary, _, cap)| (*boundary, *cap)) - .collect(); - let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); - for (boundary, expansion) in capped_equal_allocations(&caps, remaining.min(available)) { - boundary_expansion[boundary] += expansion; - remaining -= expansion; - } - } - if remaining > EPSILON && !boundaries.is_empty() { - let extra = remaining / boundaries.len() as f32; - for (boundary, _, _) in &boundaries { - boundary_expansion[*boundary] += extra; - } + let boundaries: Vec<(usize, u8, f32)> = (0..cells.len().saturating_sub(1)) + .filter(|boundary| { + placements[*boundary].0 == column && placements[boundary + 1].0 == column + }) + .filter_map(|boundary| { + let (before, after) = boundary_classes(classes, boundary)?; + let em = boundary_em(cells, boundary); + jlreq_expansion_gap(before, after, pair_spacing_em[boundary], em) + .map(|(priority, cap)| (boundary, priority, cap)) + }) + .collect(); + let slack = (max_height - used).max(0.0); + for (boundary, expansion) in jlreq_ordered_expansion(&boundaries, slack) { + boundary_expansion[boundary] += expansion; } }