mirror of
https://github.com/penpot/penpot.git
synced 2026-11-06 01:50:39 +00:00
✨ 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:
parent
cc6f183a90
commit
f44e13df70
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(¶graph, &plan, width, &lay_out);
|
||||
let (windowed, _) = horizontal_squeeze_sheds(
|
||||
¶graph,
|
||||
&plan,
|
||||
width,
|
||||
LineAdjustment::PushInFirst,
|
||||
&lay_out,
|
||||
);
|
||||
assert_eq!(
|
||||
full_layouts.get(),
|
||||
1,
|
||||
|
||||
@ -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<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(¶graph, 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(¶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::<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(¶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<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(¶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<usize>| {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user