diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index 75ba819465..8d3c48abd4 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -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`. diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow/mod.rs similarity index 88% rename from render-wasm/src/shapes/text_vertical/flow.rs rename to render-wasm/src/shapes/text_vertical/flow/mod.rs index 8264730156..5daad96bf5 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow/mod.rs @@ -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, Option)> { - 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 { - let mut tops: Vec = placements.iter().map(|(_, top)| *top).collect(); - let edges = pairwise_cell_edges(cells, text); - let graphemes: Vec = 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 { diff --git a/render-wasm/src/shapes/text_vertical/flow/pairwise.rs b/render-wasm/src/shapes/text_vertical/flow/pairwise.rs new file mode 100644 index 0000000000..fa8a91c01b --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/flow/pairwise.rs @@ -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, Option)> { + 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 { + let mut tops: Vec = placements.iter().map(|(_, top)| *top).collect(); + let edges = pairwise_cell_edges(cells, text); + let graphemes: Vec = 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 +}