✨ Make horizontal Japanese justify and squeeze follow vertical JLREQ

Horizontal Japanese justification spread each line's slack evenly over
all character gaps, while vertical columns widen gaps in JLREQ pair-table
order. Squeezing also differed: horizontal squeezed only under push-in
first, while vertical also squeezes under push-out first when push-out
cannot resolve a kinsoku break.

Move the axis-free part of the vertical allocator into text_allocation:
jlreq_expansion_gap classifies a gap from the pair table and
jlreq_ordered_expansion widens word spaces, then Japanese/Western gaps,
then solid kana and kanji, then spreads any rest. Vertical columns and
horizontal lines both use it. Horizontal collects real characters from
the laid-out lines; inserted joiners and spacing belong to the gap
between their neighbours. The last line, distribution and the warichu,
joiner and Western-word skips stay as they were.

Under push-out first, horizontal now squeezes a line when the break would
move back over more than MAX_KINSOKU_SHIFT kinsoku pairs, the limit
vertical oidashi uses, now shared from jlreq. A cheap scan for such runs
gates the pass, so other text keeps its layout cost. Push-out only never
squeezes.

This changes existing horizontal justified Japanese layouts on purpose:
punctuation aki and gaps beside ruby bases no longer widen, and a line
with no expandable gap stays short, as vertical columns do. No existing
test needed changes beyond the new line adjustment argument.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-11-05 17:02:38 +01:00
parent cc6f183a90
commit f44e13df70
7 changed files with 601 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<JustifyChar> {
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<Vec<(usize, f32)>> {
let mut spacing: Vec<Vec<(usize, f32)>> = 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<usize>) -> 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<usize>,
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<Vec<(usize, f32)>>, 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<(usize, f32)>> = 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::<f32>();
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<f32> = 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::<f32>()
})
.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(&paragraph, &plan, width, &lay_out);
let (windowed, _) = horizontal_squeeze_sheds(
&paragraph,
&plan,
width,
LineAdjustment::PushInFirst,
&lay_out,
);
assert_eq!(
full_layouts.get(),
1,

View File

@ -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(&paragraph, &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(&paragraph, &plan, width, &lay_out);
let (sheds, squeezed) = horizontal_squeeze_sheds(
&paragraph,
&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<f32> {
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(&paragraph, 1000.0, &fonts);
// Builder offset after the fifth real character, past both inserted
// inter-script spaces.
let texts: Vec<char> = 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(&paragraph, 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(&paragraph, &natural, &plan, width);
let at = |index: usize| -> f32 {
spacing[0]
.iter()
.filter(|(at, _)| *at == index)
.map(|(_, amount)| amount)
.sum::<f32>()
/ 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<f32> {
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<f32>,
),
("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<usize>,
) -> LayoutParagraph {
let mut paragraph_style = paragraph.paragraph_to_style();
paragraph_style.set_apply_rounding_hack(false);
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let span = &paragraph.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(&paragraph);
let chars: Vec<char> = 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(&paragraph, &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(&paragraph, &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(&paragraph, &plan);
[
LineAdjustment::PushInFirst,
LineAdjustment::PushOutFirst,
LineAdjustment::PushOutOnly,
]
.into_iter()
.map(|adjustment| {
let lay_out = |extra: &[Vec<(usize, f32)>], window: std::ops::Range<usize>| {
squeeze_window_layout(&paragraph, &plan, &fonts, width, extra, window)
};
let (sheds, squeezed) =
horizontal_squeeze_sheds(&paragraph, &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:?}"
);
}
}

View File

@ -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<usize> {
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;
}
}