♻️ Split the vertical pairwise flow out of the JLREQ passes

text_vertical/flow.rs held both the JLREQ column passes and the planner
the plain, Chinese and Korean policies use. Move the pairwise planner,
spacing and alignment to flow/pairwise.rs; the JLREQ passes stay in
flow/mod.rs with the flow types both share.

Also list the CJK renderer modules and their new names in
docs/cjk-layout.md.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-11-05 16:03:58 +01:00
parent 177929fd1a
commit 619f136587
3 changed files with 260 additions and 225 deletions

View File

@ -193,3 +193,22 @@ dialectal symbols/tones, automatic pronunciation, native IME acceptance and
native-reader approval remain outside this slice. Changing a base into a
group preserves the reading and uses normal ruby until a single base is
restored. Existing Pinyin and Japanese readings keep their saved behavior.
## Renderer modules
- `shapes/text_policy.rs`: `TextLayoutPolicy` (`Plain`, `Japanese`,
`Chinese(ChineseRegion)`, `Korean(KoreanWrap)`), parsed once per
paragraph from `LanguageTag`. Layout code asks it named questions.
- `shapes/jlreq.rs`: JLREQ classes (`JlreqClass`), pair rules and kinsoku.
- `shapes/line_breaks.rs`: horizontal layout-text rewrite, `OffsetMap` and
`PolicyBoundaries`.
- `shapes/cjk_chars.rs`: shared Han, kana, hangul and punctuation ranges.
- `shapes/text_attrs.rs`, `text_ruby.rs`, `text_warichu.rs` and
`text_allocation.rs`: span attributes, ruby rules, warichu and the
justify/squeeze allocators both axes share.
- `shapes/text_shaping/`: glyph shaping, orientation and font tables.
- `text_vertical/flow/`: JLREQ column planning, with the pairwise policies
in `flow/pairwise.rs`. Horizontal and vertical helpers use the same
`jlreq_*` and `pairwise_*` prefixes.
- `wasm/text/layout_attrs.rs`: the `Raw*` enums the frontend writes, including
`RawKoreanWrap`.

View File

@ -1,24 +1,22 @@
// Column planning and JLREQ spacing along the vertical flow. These passes run
// on a paragraph's `FlowCell`s before placement: they adjust cell extents
// (aki, oikomi, inter-script spacing) and pick column breaks (kinsoku,
// burasage, oidashi).
// burasage, oidashi). The other policies plan in `pairwise`.
use crate::shapes::cjk_chars::is_punctuation;
use crate::shapes::jlreq::{
aki_class, break_allowed_between, classify, pair_rule, punctuation_aki_sheds, AkiShed,
JlreqClass,
};
use crate::shapes::jlreq::{forbidden_at_line_end, forbidden_at_line_start};
use crate::shapes::line_breaks::PolicyBoundaries;
use crate::shapes::text_allocation::{
allocate_bounded_expansion, bounded_expansion, capped_equal_allocations,
};
use crate::shapes::text_policy::TextLayoutPolicy;
use crate::shapes::text_allocation::capped_equal_allocations;
use crate::shapes::{LineAdjustment, TextAlign};
use unicode_segmentation::UnicodeSegmentation;
use super::layout::{CellKind, VerticalCell};
mod pairwise;
pub(super) use pairwise::{apply_pairwise_spacing, pairwise_aligned_tops, plan_pairwise_columns};
const INTER_SCRIPT_SPACING_EM: f32 = 0.25;
/// Amounts below this are treated as zero by the spacing passes.
@ -287,223 +285,6 @@ pub(super) fn plan_jlreq_columns(items: &[FlowItem], max_height: f32) -> Vec<(us
placements
}
/// Column planning of the pairwise policies (plain, Chinese and Korean):
/// break opportunities come from `TextLayoutPolicy::break_allowed`.
/// Boundaries use the entire paragraph, so font and style runs cannot split
/// a grapheme or create a word boundary.
pub(super) fn plan_pairwise_columns(
cells: &[FlowCell],
text: &str,
policy: TextLayoutPolicy,
max_height: f32,
) -> Vec<(usize, f32)> {
let boundaries = PolicyBoundaries::new(text, policy);
let mut emergency_start = Vec::with_capacity(cells.len());
let mut unit_start = Vec::with_capacity(cells.len());
let mut prefix = vec![0.0];
for (i, flow) in cells.iter().enumerate() {
let boundary = boundaries.at(flow.cell.start);
let atomic =
i > 0 && (flow.keep_with_previous || flow.shares_previous_box || boundary.is_none());
let emergency_joined = atomic || boundary.is_some_and(|(_, emergency)| !emergency);
emergency_start.push(if i > 0 && emergency_joined {
emergency_start[i - 1]
} else {
i
});
let joined = atomic || boundary.is_some_and(|(allowed, _)| !allowed);
unit_start.push(if i > 0 && joined {
unit_start[i - 1]
} else {
i
});
prefix.push(prefix[i] + flow.cell.extent);
}
let mut unit_end = vec![cells.len(); cells.len()];
for i in (0..cells.len().saturating_sub(1)).rev() {
unit_end[i] = if unit_start[i] == unit_start[i + 1] {
unit_end[i + 1]
} else {
i + 1
};
}
let mut placements = Vec::with_capacity(cells.len());
let (mut column, mut column_start, mut cursor) = (0, 0, 0.0);
for (i, flow) in cells.iter().enumerate() {
let punctuation = |at: usize| cells.get(at).and_then(|c| c.ch).is_some_and(is_punctuation);
let solitary = (i == 0 || !punctuation(i - 1)) && !punctuation(i + 1);
let hang = if solitary {
flow.ch
.map_or(0.0, |ch| policy.hanging_em(ch, true) * flow.cell.font_size)
} else {
0.0
};
if cursor > 0.0 && cursor + flow.cell.extent - hang > max_height + FIT_TOLERANCE {
let candidate = unit_start[i];
let end = if policy.measures_whole_unit() {
unit_end[i]
} else {
i + 1
};
let fits_empty = prefix[end] - prefix[candidate] <= max_height + FIT_TOLERANCE;
let break_at = if candidate > column_start && fits_empty {
candidate
} else {
emergency_start[i]
};
// Overlong words may use emergency boundaries; punctuation,
// graphemes, explicit no-break controls and annotations may not.
if break_at > column_start {
column += 1;
cursor = 0.0;
for k in break_at..i {
placements[k] = (column, cursor);
cursor += cells[k].cell.extent;
}
column_start = break_at;
}
}
placements.push((column, cursor));
cursor += flow.cell.extent;
}
placements
}
/// Source edge characters of every cell, including sideways runs. Offsets
/// stay in UTF-16 source space; typography never inserts saved spaces.
fn pairwise_cell_edges(cells: &[FlowCell], text: &str) -> Vec<(Option<char>, Option<char>)> {
let mut offset = 0;
let chars: Vec<_> = text
.chars()
.map(|ch| {
let at = offset;
offset += ch.len_utf16();
(at, ch)
})
.collect();
cells
.iter()
.map(|flow| {
let start = chars.partition_point(|(at, _)| *at < flow.cell.start);
let end = chars.partition_point(|(at, _)| *at < flow.cell.end);
(
chars.get(start).filter(|_| start < end).map(|(_, ch)| *ch),
end.checked_sub(1)
.and_then(|at| chars.get(at))
.filter(|_| start < end)
.map(|(_, ch)| *ch),
)
})
.collect()
}
pub(super) fn apply_pairwise_spacing(cells: &mut [FlowCell], text: &str, policy: TextLayoutPolicy) {
let edges = pairwise_cell_edges(cells, text);
for i in 1..cells.len() {
if cells[i].keep_with_previous || cells[i].shares_previous_box {
continue;
}
let (Some(before), Some(after)) = (edges[i - 1].1, edges[i].0) else {
continue;
};
let em = cells[i - 1].cell.font_size.min(cells[i].cell.font_size);
let available = (cells[i - 1].cell.extent
- cells[i - 1].trailing_spacing
- 0.5 * cells[i - 1].cell.font_size)
.max(0.0);
let spacing = policy.pair_spacing_em(before, after, true);
let compression = (spacing.compression * em).min(available);
// A tate-chu-yoko composite sets in one ideograph's cell, so the
// Han/Western gap of its digits or letters does not apply.
let tate_chu_yoko =
|flow: &FlowCell| matches!(flow.cell.kind, CellKind::TateChuYoko { .. });
let gap = if tate_chu_yoko(&cells[i - 1]) || tate_chu_yoko(&cells[i]) {
0.0
} else {
spacing.script_gap * em
};
cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression)
.max(cells[i - 1].trailing_spacing.max(0.0));
cells[i - 1].cell.min_squeezed_extent = cells[i - 1].cell.extent;
}
}
/// Justify non-final columns over policy-approved gaps with bounded amounts.
/// Korean word mode only expands spaces; grapheme and annotation interiors
/// never become opportunities merely because a font/style run split them.
pub(super) fn pairwise_aligned_tops(
cells: &[FlowCell],
text: &str,
policy: TextLayoutPolicy,
placements: &[(usize, f32)],
align: TextAlign,
budget: f32,
) -> Vec<f32> {
let mut tops: Vec<f32> = placements.iter().map(|(_, top)| *top).collect();
let edges = pairwise_cell_edges(cells, text);
let graphemes: Vec<usize> = text
.graphemes(true)
.scan(0, |at, g| {
let start = *at;
*at += g.encode_utf16().count();
Some(start)
})
.collect();
let last_column = placements.last().map_or(0, |p| p.0);
let mut start = 0;
while start < cells.len() {
let column = placements[start].0;
let end = placements.partition_point(|p| p.0 <= column);
let used = placements[end - 1].1 + cells[end - 1].cell.extent;
let shift = align_offset_along_column(align, budget, used);
for top in &mut tops[start..end] {
*top += shift;
}
if (align == TextAlign::Distribute || (align == TextAlign::Justify && column < last_column))
&& is_bounded(budget)
{
let remaining = (budget - used).max(0.0);
let mut extras = vec![0.0; end - start];
let mut opportunities = Vec::new();
for i in start..end - 1 {
if cells[i + 1].keep_with_previous
|| cells[i + 1].shares_previous_box
|| graphemes.binary_search(&cells[i + 1].cell.start).is_err()
{
continue;
}
let (Some(before), Some(after)) = (edges[i].1, edges[i + 1].0) else {
continue;
};
if align == TextAlign::Distribute
&& matches!(before, '\u{00a0}' | '\u{202f}' | '\u{2060}')
|| align == TextAlign::Distribute
&& matches!(after, '\u{00a0}' | '\u{202f}' | '\u{2060}')
{
continue;
}
let em = cells[i].cell.font_size.min(cells[i + 1].cell.font_size);
let distribute = (align == TextAlign::Distribute).then_some(budget);
if let Some((priority, cap)) =
bounded_expansion(policy, before, after, em, distribute)
{
opportunities.push((i, priority, cap));
}
}
for (i, amount) in allocate_bounded_expansion(&opportunities, remaining) {
extras[i - start] += amount;
}
let mut extra = 0.0;
for i in start..end {
tops[i] += extra;
extra += extras[i - start];
}
}
start = end;
}
tops
}
/// True when `max_height` is a real wrap budget (auto-width columns are
/// unbounded).
pub(super) fn is_bounded(max_height: f32) -> bool {

View File

@ -0,0 +1,235 @@
//! Column planning, spacing and alignment of the pairwise policies (plain,
//! Chinese and Korean) along the vertical flow. Break opportunities, spacing
//! and justification come from `TextLayoutPolicy` pair rules over the whole
//! paragraph text.
use crate::shapes::cjk_chars::is_punctuation;
use crate::shapes::line_breaks::PolicyBoundaries;
use crate::shapes::text_allocation::{allocate_bounded_expansion, bounded_expansion};
use crate::shapes::text_policy::TextLayoutPolicy;
use crate::shapes::TextAlign;
use unicode_segmentation::UnicodeSegmentation;
use super::super::layout::CellKind;
use super::{align_offset_along_column, is_bounded, FlowCell, FIT_TOLERANCE};
/// Column planning of the pairwise policies (plain, Chinese and Korean):
/// break opportunities come from `TextLayoutPolicy::break_allowed`.
/// Boundaries use the entire paragraph, so font and style runs cannot split
/// a grapheme or create a word boundary.
pub(in crate::shapes::text_vertical) fn plan_pairwise_columns(
cells: &[FlowCell],
text: &str,
policy: TextLayoutPolicy,
max_height: f32,
) -> Vec<(usize, f32)> {
let boundaries = PolicyBoundaries::new(text, policy);
let mut emergency_start = Vec::with_capacity(cells.len());
let mut unit_start = Vec::with_capacity(cells.len());
let mut prefix = vec![0.0];
for (i, flow) in cells.iter().enumerate() {
let boundary = boundaries.at(flow.cell.start);
let atomic =
i > 0 && (flow.keep_with_previous || flow.shares_previous_box || boundary.is_none());
let emergency_joined = atomic || boundary.is_some_and(|(_, emergency)| !emergency);
emergency_start.push(if i > 0 && emergency_joined {
emergency_start[i - 1]
} else {
i
});
let joined = atomic || boundary.is_some_and(|(allowed, _)| !allowed);
unit_start.push(if i > 0 && joined {
unit_start[i - 1]
} else {
i
});
prefix.push(prefix[i] + flow.cell.extent);
}
let mut unit_end = vec![cells.len(); cells.len()];
for i in (0..cells.len().saturating_sub(1)).rev() {
unit_end[i] = if unit_start[i] == unit_start[i + 1] {
unit_end[i + 1]
} else {
i + 1
};
}
let mut placements = Vec::with_capacity(cells.len());
let (mut column, mut column_start, mut cursor) = (0, 0, 0.0);
for (i, flow) in cells.iter().enumerate() {
let punctuation = |at: usize| cells.get(at).and_then(|c| c.ch).is_some_and(is_punctuation);
let solitary = (i == 0 || !punctuation(i - 1)) && !punctuation(i + 1);
let hang = if solitary {
flow.ch
.map_or(0.0, |ch| policy.hanging_em(ch, true) * flow.cell.font_size)
} else {
0.0
};
if cursor > 0.0 && cursor + flow.cell.extent - hang > max_height + FIT_TOLERANCE {
let candidate = unit_start[i];
let end = if policy.measures_whole_unit() {
unit_end[i]
} else {
i + 1
};
let fits_empty = prefix[end] - prefix[candidate] <= max_height + FIT_TOLERANCE;
let break_at = if candidate > column_start && fits_empty {
candidate
} else {
emergency_start[i]
};
// Overlong words may use emergency boundaries; punctuation,
// graphemes, explicit no-break controls and annotations may not.
if break_at > column_start {
column += 1;
cursor = 0.0;
for k in break_at..i {
placements[k] = (column, cursor);
cursor += cells[k].cell.extent;
}
column_start = break_at;
}
}
placements.push((column, cursor));
cursor += flow.cell.extent;
}
placements
}
/// Source edge characters of every cell, including sideways runs. Offsets
/// stay in UTF-16 source space; typography never inserts saved spaces.
fn pairwise_cell_edges(cells: &[FlowCell], text: &str) -> Vec<(Option<char>, Option<char>)> {
let mut offset = 0;
let chars: Vec<_> = text
.chars()
.map(|ch| {
let at = offset;
offset += ch.len_utf16();
(at, ch)
})
.collect();
cells
.iter()
.map(|flow| {
let start = chars.partition_point(|(at, _)| *at < flow.cell.start);
let end = chars.partition_point(|(at, _)| *at < flow.cell.end);
(
chars.get(start).filter(|_| start < end).map(|(_, ch)| *ch),
end.checked_sub(1)
.and_then(|at| chars.get(at))
.filter(|_| start < end)
.map(|(_, ch)| *ch),
)
})
.collect()
}
pub(in crate::shapes::text_vertical) fn apply_pairwise_spacing(
cells: &mut [FlowCell],
text: &str,
policy: TextLayoutPolicy,
) {
let edges = pairwise_cell_edges(cells, text);
for i in 1..cells.len() {
if cells[i].keep_with_previous || cells[i].shares_previous_box {
continue;
}
let (Some(before), Some(after)) = (edges[i - 1].1, edges[i].0) else {
continue;
};
let em = cells[i - 1].cell.font_size.min(cells[i].cell.font_size);
let available = (cells[i - 1].cell.extent
- cells[i - 1].trailing_spacing
- 0.5 * cells[i - 1].cell.font_size)
.max(0.0);
let spacing = policy.pair_spacing_em(before, after, true);
let compression = (spacing.compression * em).min(available);
// A tate-chu-yoko composite sets in one ideograph's cell, so the
// Han/Western gap of its digits or letters does not apply.
let tate_chu_yoko =
|flow: &FlowCell| matches!(flow.cell.kind, CellKind::TateChuYoko { .. });
let gap = if tate_chu_yoko(&cells[i - 1]) || tate_chu_yoko(&cells[i]) {
0.0
} else {
spacing.script_gap * em
};
cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression)
.max(cells[i - 1].trailing_spacing.max(0.0));
cells[i - 1].cell.min_squeezed_extent = cells[i - 1].cell.extent;
}
}
/// Justify non-final columns over policy-approved gaps with bounded amounts.
/// Korean word mode only expands spaces; grapheme and annotation interiors
/// never become opportunities merely because a font/style run split them.
pub(in crate::shapes::text_vertical) fn pairwise_aligned_tops(
cells: &[FlowCell],
text: &str,
policy: TextLayoutPolicy,
placements: &[(usize, f32)],
align: TextAlign,
budget: f32,
) -> Vec<f32> {
let mut tops: Vec<f32> = placements.iter().map(|(_, top)| *top).collect();
let edges = pairwise_cell_edges(cells, text);
let graphemes: Vec<usize> = text
.graphemes(true)
.scan(0, |at, g| {
let start = *at;
*at += g.encode_utf16().count();
Some(start)
})
.collect();
let last_column = placements.last().map_or(0, |p| p.0);
let mut start = 0;
while start < cells.len() {
let column = placements[start].0;
let end = placements.partition_point(|p| p.0 <= column);
let used = placements[end - 1].1 + cells[end - 1].cell.extent;
let shift = align_offset_along_column(align, budget, used);
for top in &mut tops[start..end] {
*top += shift;
}
if (align == TextAlign::Distribute || (align == TextAlign::Justify && column < last_column))
&& is_bounded(budget)
{
let remaining = (budget - used).max(0.0);
let mut extras = vec![0.0; end - start];
let mut opportunities = Vec::new();
for i in start..end - 1 {
if cells[i + 1].keep_with_previous
|| cells[i + 1].shares_previous_box
|| graphemes.binary_search(&cells[i + 1].cell.start).is_err()
{
continue;
}
let (Some(before), Some(after)) = (edges[i].1, edges[i + 1].0) else {
continue;
};
if align == TextAlign::Distribute
&& matches!(before, '\u{00a0}' | '\u{202f}' | '\u{2060}')
|| align == TextAlign::Distribute
&& matches!(after, '\u{00a0}' | '\u{202f}' | '\u{2060}')
{
continue;
}
let em = cells[i].cell.font_size.min(cells[i + 1].cell.font_size);
let distribute = (align == TextAlign::Distribute).then_some(budget);
if let Some((priority, cap)) =
bounded_expansion(policy, before, after, em, distribute)
{
opportunities.push((i, priority, cap));
}
}
for (i, amount) in allocate_bounded_expansion(&opportunities, remaining) {
extras[i - start] += amount;
}
let mut extra = 0.0;
for i in start..end {
tops[i] += extra;
extra += extras[i - start];
}
}
start = end;
}
tops
}