Files
designcraft/crates/compose/src/breaker.rs
T
aiongg f977482e06 Merge #310: Justification: tight word spacing doesn't isolate a word; fixed-width spaces don't stretch
Conflict: crates/compose/src/breaker.rs: next's multi-line Item::boxed/penalty with edge_elastic and start_elastic plus #310's over; glue in items() keeps next's edge_elastic and sets #310's over; kp_pass first node keeps next's first_box/first_hang/first_elastic and ty/tz plus #310's to
2026-10-10 09:18:18 +00:00

992 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Line breaking: the paragraph composer (Knuth–Plass total fit), the single-line composer
//! (greedy first fit) and balanced ragged lines. All work on a paragraph's glyphs and a per-line
//! measure function.
//!
//! Justification uses *prioritised* elastic material (plan: typography §5.2): word spaces first,
//! then letter spacing (every glyph gets elastic width from the paragraph's letter-spacing range),
//! then glyph scaling. The adjustment ratio charges letter and glyph adjustments extra
//! ([`TIER_COST`]), so the composer prefers word-space changes, then hyphenation, then letter
//! spacing, then glyph scaling.
use crate::shape::{Glyph, SOFT_HYPHEN};
/// One line chosen by a breaker: glyphs `start..end` are visible; `next` is where the next line starts.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Break {
pub start: usize,
pub end: usize,
pub next: usize,
/// Broken at a hyphenation point (a hyphen glyph must be added).
pub hyphen: bool,
/// Ended by a forced break (paragraph end, forced line break, column/frame/page break).
pub forced: bool,
}
/// Spacing parameters (from the paragraph's Justification and Hyphenation settings), as fractions.
#[derive(Clone, Copy, Debug)]
pub struct Spacing {
pub justify: bool,
pub word_min: f64,
pub word_desired: f64,
pub word_max: f64,
/// Letter spacing, as fractions of the space width added between glyphs.
pub letter_min: f64,
pub letter_desired: f64,
pub letter_max: f64,
/// Horizontal glyph scaling (1 = 100%).
pub glyph_min: f64,
pub glyph_desired: f64,
pub glyph_max: f64,
/// Penalty for a hyphenated break (0 = free, larger = fewer hyphens).
pub hyphen_penalty: f64,
/// Maximum consecutive hyphenated lines (0 = unlimited) — a hard constraint.
pub hyphen_limit: u32,
/// Ragged-right stretchability (points) for unjustified paragraphs.
pub ragged_stretch: f64,
/// Hyphenation zone (points, unjustified text only): a word is not hyphenated when breaking
/// before it would leave less than this much space at the end of the line.
pub hyph_zone: f64,
/// Optical margin alignment: punctuation hangs outside the measure (counted as extra width).
pub optical: bool,
/// Korean text breaks between syllables instead of at spaces.
pub korean_char_breaks: bool,
/// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only.
pub kinsoku_priority: u8,
}
impl Default for Spacing {
fn default() -> Self {
Spacing {
justify: false,
word_min: 0.8,
word_desired: 1.0,
word_max: 1.33,
letter_min: 0.0,
letter_desired: 0.0,
letter_max: 0.0,
glyph_min: 1.0,
glyph_desired: 1.0,
glyph_max: 1.0,
hyphen_penalty: 275.0,
hyphen_limit: 3,
ragged_stretch: 24.0,
hyph_zone: 0.0,
optical: false,
korean_char_breaks: false,
kinsoku_priority: 0,
}
}
}
impl Spacing {
/// Per-tier (word, letter, glyph) stretch and shrink of glyph `g` as a box.
fn box_elastic(&self, g: &Glyph) -> ([f64; 3], [f64; 3]) {
if g.locked_advance {
return ([0.0; 3], [0.0; 3]);
}
let moji_stretch = if self.justify { g.moji.stretch } else { 0.0 };
let moji_shrink = if self.kinsoku_priority == 3 { 0.0 } else { g.moji.shrink };
if !self.justify {
return ([0.0; 3], [moji_shrink, 0.0, 0.0]);
}
let natural = g.adv / self.glyph_desired.max(0.01);
(
[moji_stretch, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)],
[moji_shrink, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)],
)
}
/// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic).
fn space_elastic(&self, g: &Glyph) -> (f64, f64) {
if !elastic_space(g) {
return (0.0, 0.0);
}
(g.space * (self.word_max - self.word_desired).max(0.0), g.space * (self.word_desired - self.word_min).max(0.0))
}
/// The unit in which stretch past the maximum word spacing is rated (see [`tiered_ratio`]): the
/// word space's own stretch, but at least [`OVERFLOW_STRETCH`] of its width, so a tight
/// spacing range doesn't make a loose line with several word spaces look worse than a line
/// letter-spaced across the measure.
fn space_overflow(&self, g: &Glyph) -> f64 {
if !elastic_space(g) {
return 0.0;
}
g.space * (self.word_max - self.word_desired).max(OVERFLOW_STRETCH)
}
}
/// Does `g` stretch and shrink as a word space? Fixed-width spaces (em, en, thin…) don't.
pub(crate) fn elastic_space(g: &Glyph) -> bool {
g.ch == ' ' || (g.ch == '\u{3000}' && g.ideographic_space_elastic)
}
/// Minimum stretch of a word space, as a fraction of its width, for rating lines that stretch
/// past the maximum word spacing (the default range's 133% − 100%).
const OVERFLOW_STRETCH: f64 = 0.33;
/// Relative cost of using each tier of elastic material (word, letter, glyph).
pub const TIER_COST: [f64; 3] = [1.0, 0.75, 1.0];
/// Effective adjustment ratio for `d` points of stretch (`d > 0`) or shrink (`d < 0`), consuming
/// the tiers in priority order. Returns `(r, feasible)`; shrinking beyond all tiers is infeasible.
/// Stretch past every tier goes into the word spaces and is rated in units of `over` (their
/// overflow stretch, see [`Spacing::space_overflow`]); a line without word spaces rates it
/// against its letter and glyph stretch.
pub fn tiered_ratio(d: f64, y: [f64; 3], z: [f64; 3], over: f64) -> (f64, bool) {
const EPS: f64 = 1e-9;
if d.abs() < EPS {
return (0.0, true);
}
let (mut rem, cap) = if d > 0.0 { (d, y) } else { (-d, z) };
let mut r = 0.0;
for t in 0..3 {
if cap[t] > EPS && rem > EPS {
let take = rem.min(cap[t]);
r += TIER_COST[t] * take / cap[t];
rem -= take;
}
}
if d < 0.0 {
return (-r, rem <= 1e-6);
}
if rem > 1e-6 {
// Beyond every maximum: word spaces grow without bound (an H&J violation).
let unit = if over > EPS { over } else { cap.iter().sum::<f64>() };
r = if unit > EPS { r + rem / unit } else { INF };
}
(r, true)
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Box,
Glue,
Penalty,
}
#[derive(Clone, Copy, Debug)]
struct Item {
kind: Kind,
w: f64,
st: [f64; 3],
sh: [f64; 3],
/// Penalty value (penalties only).
p: f64,
flagged: bool,
/// An automatic/discretionary hyphenation point (subject to the hyphenation zone).
auto: bool,
/// Breaks: optical hang of the glyph ending the line. Boxes: hang of the glyph starting one.
hang: f64,
edge_elastic: [f64; 2],
start_elastic: [f64; 2],
/// Auto hyphenation points: index of the break opportunity before the word (or `NONE`).
zone_from: usize,
/// An emergency break inside a word wider than the measure.
emergency: bool,
/// Glue: stretch past the maximum word spacing ([`Spacing::space_overflow`]).
over: f64,
}
const NONE: usize = usize::MAX;
impl Item {
fn boxed(w: f64, st: [f64; 3], sh: [f64; 3]) -> Item {
Item {
kind: Kind::Box,
w,
st,
sh,
p: 0.0,
flagged: false,
auto: false,
hang: 0.0,
edge_elastic: [0.0; 2],
start_elastic: [0.0; 2],
zone_from: NONE,
emergency: false,
over: 0.0,
}
}
fn glue(w: f64, st: f64, sh: f64) -> Item {
Item {
kind: Kind::Glue,
w,
st: [st, 0.0, 0.0],
sh: [sh, 0.0, 0.0],
p: 0.0,
flagged: false,
auto: false,
hang: 0.0,
edge_elastic: [0.0; 2],
start_elastic: [0.0; 2],
zone_from: NONE,
emergency: false,
over: 0.0,
}
}
fn penalty(w: f64, p: f64, flagged: bool) -> Item {
Item {
kind: Kind::Penalty,
w,
st: [0.0; 3],
sh: [0.0; 3],
p,
flagged,
auto: false,
hang: 0.0,
edge_elastic: [0.0; 2],
start_elastic: [0.0; 2],
zone_from: NONE,
emergency: false,
over: 0.0,
}
}
}
const INF: f64 = 10000.0;
/// A line whose natural width exceeds its measure by no more than this (points) still fits: frame
/// widths and glyph advances are rounded, so a line that exactly fills the measure can come out a
/// hair too wide.
const FIT_EPS: f64 = 0.01;
/// Penalty for an emergency break inside a word wider than the measure: high, so a paragraph
/// takes no more of them than it must, but below [`INF`] so they are legal.
const EMERGENCY_PENALTY: f64 = 5000.0;
/// Does the glyph force a line end after it?
/// A line may break between `a` and `b` in CJK text (any break next to an ideograph, kana or
/// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small
/// kana or the prolonged sound mark, and none ends with opening brackets.
pub fn cjk_break_between(a: char, b: char) -> bool {
(is_cjk(a) || is_cjk(b)) && kinsoku_allows(a, b)
}
fn is_cjk(c: char) -> bool {
crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F)
}
fn kinsoku_allows(a: char, b: char) -> bool {
const NO_START: &str = "、。,.・:;?!ー)」』】〕〉》}]〙〗ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ々〻ゝゞヽヾ!),.:;?]}";
const NO_END: &str = "(「『【〔〈《{[〘〖([{";
!NO_START.contains(b) && !NO_END.contains(a)
}
/// Hangul, and Han ideographs (hanja) in Korean text: Korean is set word by word (KLREQ), so
/// these letters break like Western ones, at spaces.
fn korean_letter(g: &Glyph) -> bool {
let c = g.ch as u32;
let hangul = matches!(c, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF);
let han = matches!(c, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF);
hangul || (han && g.lang.is_some_and(|t| designcraft_doc::language_subtag(t) == "ko"))
}
/// Is the place between glyphs `a` and `b` one where CJK text may break (next to an ideograph, kana
/// or hangul)? Korean letters take no breaks between characters unless the paragraph asks for
/// character-based breaking.
pub(crate) fn cjk_pair(a: &Glyph, b: &Glyph, korean_char_breaks: bool) -> bool {
let cjk = |g: &Glyph| is_cjk(g.ch) && (korean_char_breaks || !korean_letter(g));
cjk(a) || cjk(b)
}
/// [`cjk_break_between`] for glyphs `a` and `b` (see [`cjk_pair`]).
fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
cjk_pair(a, b, sp.korean_char_breaks) && kinsoku_allows(a.ch, b.ch)
}
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
c == FORCED_LINE_BREAK || is_break_char(c)
}
/// Optical margin protrusion of `c` as fractions of its advance: (left edge, right edge).
pub fn hang(c: char) -> (f64, f64) {
match c {
'.' | ',' => (0.0, 1.0),
'-' | '\u{2010}' | '\u{2011}' | '\u{AD}' => (0.75, 0.75),
'\'' | '"' | '‘' | '’' | '“' | '”' | '‚' | '„' | '‹' | '›' | '«' | '»' => (1.0, 1.0),
'\u{2013}' | '\u{2014}' => (0.5, 0.5),
':' | ';' => (0.0, 0.5),
'!' | '?' => (0.0, 0.2),
'*' => (0.3, 0.3),
'(' | '[' | '{' => (0.1, 0.0),
')' | ']' | '}' => (0.0, 0.1),
'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' => (0.05, 0.05),
'T' => (0.05, 0.05),
'O' | 'C' | 'G' | 'Q' | 'o' | 'c' => (0.03, 0.03),
_ => (0.0, 0.0),
}
}
fn hang_left(g: &Glyph) -> f64 {
hang(g.ch).0 * g.adv
}
fn right_hang(g: &Glyph, sp: &Spacing) -> f64 {
g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end
}
fn hang_right(g: &Glyph) -> f64 {
hang(g.ch).1 * g.adv
}
/// Advance of the hyphen glyph that would follow `g` (cached per face).
fn hyphen_width(g: &Glyph, cache: &mut Vec<(u32, f64)>) -> f64 {
let id = g.face.id();
let units = match cache.iter().find(|c| c.0 == id) {
Some(c) => c.1,
None => {
let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']);
let u = g.face.advance(gid);
cache.push((id, u));
u
}
};
units * g.sx
}
/// Push a break inside a word. Ragged text gets the line-end stretch around it
/// (`penalty(∞) glue(0, s) penalty glue(0, −s)`), so hyphenated ragged lines are measured like
/// lines broken at spaces.
fn push_inword(it: &mut Vec<Item>, ig: &mut Vec<usize>, p: Item, g: usize, sp: &Spacing) {
if sp.justify {
it.push(p);
ig.push(g);
return;
}
it.push(Item::penalty(0.0, INF, false));
it.push(Item::glue(0.0, sp.ragged_stretch, 0.0));
it.push(p);
it.push(Item::glue(0.0, -sp.ragged_stretch, 0.0));
ig.extend([g; 4]);
}
/// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens).
/// `emergency_after[i]` allows an emergency break after glyph `i` (see [`overlong_words`]).
fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
let mut it: Vec<Item> = Vec::with_capacity(glyphs.len() * 2 + 2);
let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2);
let n = glyphs.len();
let mut hy_cache = Vec::new();
let mut last_space = NONE;
for (i, g) in glyphs.iter().enumerate() {
let prev_hang = if i > 0 { right_hang(&glyphs[i - 1], sp) } else { 0.0 };
if is_forced(g.ch) {
it.push(Item::glue(0.0, INF, 0.0));
ig.push(i);
let mut p = Item::penalty(0.0, -INF, false);
p.hang = prev_hang;
if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) {
p.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3);
}
it.push(p);
ig.push(i + 1);
last_space = NONE;
continue;
}
if g.is_space() && !g.no_break && g.break_after != Some(false) {
let w = g.adv;
if sp.justify {
let (st, sh) = sp.space_elastic(g);
let mut gl = Item::glue(w, st, sh);
gl.hang = prev_hang;
if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) {
gl.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3);
}
gl.over = sp.space_overflow(g);
last_space = it.len();
it.push(gl);
ig.push(i);
} else {
// Ragged right (Knuth): glue(0, s, 0) penalty(0) glue(w, -s, 0).
it.push(Item::glue(0.0, sp.ragged_stretch, 0.0));
ig.push(i);
let mut p = Item::penalty(0.0, 0.0, false);
p.hang = prev_hang;
if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) {
p.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3);
}
last_space = it.len();
it.push(p);
ig.push(i);
it.push(Item::glue(w, -sp.ragged_stretch, 0.0));
ig.push(i);
}
continue;
}
if g.ch == SOFT_HYPHEN {
let hw = glyphs[..i].iter().rev().find(|g| g.ch != SOFT_HYPHEN).map_or(g.size * 0.33, |p| hyphen_width(p, &mut hy_cache));
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
push_inword(&mut it, &mut ig, p, i + 1, sp);
continue;
}
let (st, sh) = sp.box_elastic(g);
let mut bx = Item::boxed(g.adv, st, sh);
bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start;
bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3);
it.push(bx);
ig.push(i);
let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space());
if i + 1 < n && !next_is_space && !g.no_break && g.break_after != Some(false) {
if matches!(g.ch, '-' | '\u{2010}') {
let mut p = Item::penalty(0.0, 50.0, true);
p.hang = right_hang(g, sp);
p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3);
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) {
let mut p = Item::penalty(0.0, 0.0, false);
p.hang = right_hang(g, sp);
p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3);
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if hyph_after[i] {
let hw = hyphen_width(g, &mut hy_cache);
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if emergency_after.get(i) == Some(&true) {
let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false);
p.emergency = true;
push_inword(&mut it, &mut ig, p, i + 1, sp);
}
}
}
// Paragraph end: fill glue + forced break.
it.push(Item::glue(0.0, INF, 0.0));
ig.push(n);
let mut p = Item::penalty(0.0, -INF, false);
p.hang = if n > 0 { right_hang(&glyphs[n - 1], sp) } else { 0.0 };
if let Some(g) = glyphs.last() {
p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3);
}
it.push(p);
ig.push(n);
(it, ig)
}
#[derive(Clone, Copy, Debug)]
struct Node {
pos: usize,
line: usize,
fitness: u8,
tw: f64,
ty: [f64; 3],
tz: [f64; 3],
to: f64,
demerits: f64,
prev: Option<usize>,
hyphens: u32,
flagged: bool,
}
/// Knuth–Plass total-fit line breaking. `width(line)` gives each line's measure.
pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
if glyphs.is_empty() {
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
}
let (mut items, mut ig) = items(glyphs, hyph_after, &[], sp);
let narrowest = (0..=glyphs.len().min(64)).map(width).fold(f64::INFINITY, f64::min).max(1.0);
if let Some(emergency_after) = overlong_words(&items, &ig, glyphs, narrowest) {
(items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp);
}
for tolerance in [2.5, 12.0, f64::INFINITY] {
if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) {
return b;
}
}
greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0)
}
/// Glyphs after which a word wider than `measure` may take an emergency break, or `None` when every
/// word fits. A word is a run of boxes between two break opportunities (so a word with hyphenation
/// points only gets emergency breaks where a part between them is still too wide), measured at
/// its narrowest (fully shrunk) width. Emergency breaks respect no-break text and kinsoku.
fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) -> Option<Vec<bool>> {
let mut out: Option<Vec<bool>> = None;
let mut seg: Option<(usize, usize)> = None;
let mut w = 0.0;
for (k, it) in items.iter().enumerate() {
match it.kind {
Kind::Box => {
let g = ig.get(k).copied().unwrap_or(0);
seg = Some(seg.map_or((g, g), |(a, _)| (a, g)));
w += it.w - it.sh.iter().sum::<f64>();
}
Kind::Penalty if it.p >= INF => {}
Kind::Glue | Kind::Penalty => {
if let Some((a, b)) = seg.take()
&& w > measure + FIT_EPS
{
let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]);
for i in a..b {
let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue };
let ok = !g.no_break
&& g.break_after != Some(false)
&& next.ch != SOFT_HYPHEN
&& !is_forced(next.ch)
&& kinsoku_allows(g.ch, next.ch);
if let Some(m) = marks.get_mut(i) {
*m = ok;
}
}
}
w = 0.0;
}
}
}
out
}
/// Upper bound on the hyphen-count states tracked per breakpoint.
const MAX_HYPHEN_STATES: usize = 8;
#[allow(clippy::too_many_arguments)]
fn kp_pass(
items: &[Item],
ig: &[usize],
glyphs: &[Glyph],
sp: &Spacing,
width: &dyn Fn(usize) -> f64,
tol: f64,
emergency: bool,
) -> Option<Vec<Break>> {
let m = items.len();
// Prefix sums (before item i).
let mut sw = vec![0.0; m + 1];
let mut sy = vec![[0.0; 3]; m + 1];
let mut sz = vec![[0.0; 3]; m + 1];
let mut so = vec![0.0; m + 1];
for (i, it) in items.iter().enumerate() {
let (w, y, z) = match it.kind {
Kind::Box | Kind::Glue => (it.w, it.st, it.sh),
Kind::Penalty => (0.0, [0.0; 3], [0.0; 3]),
};
sw[i + 1] = sw[i] + w;
so[i + 1] = so[i] + it.over;
for t in 0..3 {
sy[i + 1][t] = sy[i][t] + y[t];
sz[i + 1][t] = sz[i][t] + z[t];
}
}
let first_box = items.iter().find(|i| i.kind == Kind::Box);
let first_hang = first_box.map_or(0.0, |b| b.hang);
let first_elastic = first_box.map_or([0.0; 2], |b| b.start_elastic);
let mut arena: Vec<Node> = vec![Node {
pos: 0,
line: 0,
fitness: 1,
tw: first_hang,
ty: [-first_elastic[0], 0.0, 0.0],
tz: [-first_elastic[1], 0.0, 0.0],
to: 0.0,
demerits: 0.0,
prev: None,
hyphens: 0,
flagged: false,
}];
let limit = sp.hyphen_limit as usize;
let states = if limit == 0 { 2 } else { (limit + 1).min(MAX_HYPHEN_STATES) };
let mut active: Vec<usize> = vec![0];
let mut best: Vec<Option<(f64, usize)>> = vec![None; 4 * states];
let mut keep = Vec::new();
for b in 0..m {
let it = items[b];
let (is_break, pw, pp, flagged) = match it.kind {
Kind::Penalty if it.p < INF => (true, it.w, it.p, it.flagged),
Kind::Glue if b > 0 && items[b - 1].kind == Kind::Box => (true, 0.0, 0.0, false),
_ => (false, 0.0, 0.0, false),
};
if !is_break {
continue;
}
let forced = pp <= -INF;
best.iter_mut().for_each(|x| *x = None);
keep.clear();
let mut last_removed: Option<usize> = None;
for &a in &active {
let n = arena[a];
let l = sw[b] - n.tw + pw - it.hang;
let target = width(n.line).max(1.0);
let mut y = [0.0; 3];
let mut z = [0.0; 3];
for t in 0..3 {
y[t] = sy[b][t] - n.ty[t];
z[t] = sz[b][t] - n.tz[t];
}
y[0] = (y[0] + it.edge_elastic[0]).max(0.0);
z[0] = (z[0] + it.edge_elastic[1]).max(0.0);
// A line that overruns its measure by a rounding error fits exactly.
let d = target - l;
let d = if d < 0.0 && d > -FIT_EPS { 0.0 } else { d };
let (mut r, feasible) = tiered_ratio(d, y, z, so[b] - n.to);
if it.emergency && sp.justify && r > 0.0 {
// A piece of an overlong word usually has no elastic material: rate it by the
// space it leaves, so the fullest piece wins (the break at the last glyph that fits).
r = d / target;
}
if feasible && !forced {
keep.push(a);
} else {
last_removed = Some(a);
}
if !feasible || (r > tol && !forced) {
continue;
}
if flagged {
// Hyphen limit: a hard constraint.
if limit > 0 && n.hyphens as usize >= limit {
continue;
}
// Hyphenation zone (unjustified): break before the word instead when that leaves
// less than the zone at the line end.
if it.auto && !sp.justify && sp.hyph_zone > 0.0 && it.zone_from != NONE && it.zone_from > n.pos {
let before = sw[it.zone_from] - n.tw - items[it.zone_from].hang;
if target - before < sp.hyph_zone {
continue;
}
}
}
let preference = match sp.kinsoku_priority {
1 if r > 0.0 => 4.0,
2 if r < 0.0 => 4.0,
_ => 1.0,
};
let bad = preference * 100.0 * r.abs().powi(3);
let lp = 10.0 + bad;
let mut d = if pp >= 0.0 {
lp * lp + pp * pp
} else if pp > -INF {
lp * lp - pp * pp
} else {
lp * lp
};
if flagged && n.flagged {
d += 3000.0;
}
let fit = if r < -0.5 {
0
} else if r <= 0.5 {
1
} else if r <= 1.0 {
2
} else {
3
};
if (fit as i32 - n.fitness as i32).abs() > 1 {
d += 3000.0;
}
let hs = if flagged { (n.hyphens as usize + 1).min(states - 1) } else { 0 };
let total = n.demerits + d;
let slot = &mut best[fit * states + hs];
if slot.is_none_or(|(bd, _)| total < bd) {
*slot = Some((total, a));
}
}
// Emergency: nothing can reach this break and nothing remains active → accept an overfull line.
if emergency
&& keep.is_empty()
&& best.iter().all(Option::is_none)
&& let Some(a) = last_removed
{
best[0] = Some((arena[a].demerits + 1e8, a));
}
std::mem::swap(&mut active, &mut keep);
if best.iter().all(Option::is_none) {
if active.is_empty() {
return None;
}
continue;
}
// Totals after the break: skip glue and penalties up to the next box.
let (mut tw, mut ty, mut tz, mut to) = (sw[b], sy[b], sz[b], so[b]);
let mut i = b;
while i < m {
let x = items[i];
match x.kind {
Kind::Box => {
tw += x.hang;
ty[0] -= x.start_elastic[0];
tz[0] -= x.start_elastic[1];
break;
}
Kind::Glue => {
tw += x.w;
to += x.over;
for t in 0..3 {
ty[t] += x.st[t];
tz[t] += x.sh[t];
}
}
Kind::Penalty if x.p <= -INF && i > b => break,
Kind::Penalty => {}
}
i += 1;
}
// Knuth's pruning: a class is only worth a node if it is within the fitness demerit of the best.
let min_d = best.iter().flatten().map(|x| x.0).fold(f64::INFINITY, f64::min);
for (k, c) in best.iter().enumerate() {
if let Some((d, a)) = *c
&& d <= min_d + 3000.0
{
let prev = arena[a];
arena.push(Node {
pos: b,
line: prev.line + 1,
fitness: (k / states) as u8,
tw,
ty,
tz,
to,
demerits: d,
prev: Some(a),
hyphens: if flagged { prev.hyphens + 1 } else { 0 },
flagged,
});
active.push(arena.len() - 1);
}
}
}
// The final forced break: best node at position m-1.
let end = active.iter().copied().filter(|&a| arena[a].pos == m - 1).min_by(|&a, &b| arena[a].demerits.total_cmp(&arena[b].demerits))?;
let mut chain = Vec::new();
let mut cur = Some(end);
while let Some(c) = cur {
if arena[c].prev.is_some() {
chain.push(arena[c].pos);
}
cur = arena[c].prev;
}
chain.reverse();
Some(breaks_from_positions(items, ig, glyphs, &chain))
}
fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: &[usize]) -> Vec<Break> {
let n = glyphs.len();
let mut out = Vec::with_capacity(chain.len());
let mut start = 0;
for &b in chain {
let it = items[b];
let (end, hyphen, forced) = match it.kind {
Kind::Penalty => {
let e = ig[b].min(n);
// A flagged penalty with width = an inserted hyphen (not an explicit '-').
let explicit = e > 0 && matches!(glyphs[e - 1].ch, '-' | '\u{2010}');
(e, it.flagged && it.w > 0.0 && !explicit || (it.flagged && e > 0 && glyphs[e - 1].ch == SOFT_HYPHEN), it.p <= -INF)
}
Kind::Glue | Kind::Box => (ig[b], false, false),
};
// Trim trailing spaces from the visible range; skip leading spaces of the next line.
let mut vis_end = end;
while vis_end > start && glyphs[vis_end - 1].is_space() {
vis_end -= 1;
}
let mut next = end;
while next < n && glyphs[next].is_space() {
next += 1;
}
let forced_glyph = end > 0 && end <= n && is_forced(glyphs[end - 1].ch);
out.push(Break { start, end: vis_end, next, hyphen, forced: forced || forced_glyph });
start = next;
}
if out.is_empty() {
out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true });
}
// Lines produced past the paragraph end are dropped, except the empty line after a trailing
// forced line break (a trailing column, frame or page break ends the paragraph).
let trailing_forced = glyphs.last().is_some_and(|g| g.ch == designcraft_doc::story::FORCED_LINE_BREAK);
out.retain(|b| b.start < n || (b.start == n && trailing_forced));
out
}
/// Where a line with no break opportunity before glyph `i` (a word wider than the measure) is
/// split: before `i`, or earlier where kinsoku forbids that (no line starts with closing
/// punctuation).
fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize {
let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if kinsoku_allows(a.ch, b.ch));
(start + 1..=i).rev().find(|&j| allowed(j)).unwrap_or(i)
}
/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs).
///
/// `tab(line, x, i)` is the advance of the tab glyph `i` when it starts `x` from the start of
/// line `line`: tabs have no width until they reach their stop, which depends on where they sit.
pub fn greedy(
glyphs: &[Glyph],
hyph_after: &[bool],
sp: &Spacing,
width: &dyn Fn(usize) -> f64,
tab: &dyn Fn(usize, f64, usize) -> f64,
) -> Vec<Break> {
let n = glyphs.len();
let mut out = Vec::new();
let mut start = 0;
let mut line = 0;
let mut hyphens = 0u32;
let mut hy_cache = Vec::new();
if n == 0 {
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
}
let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit;
while start < n {
let w = width(line).max(1.0);
let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start;
let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1];
let mut last_natural: Option<(usize, bool)> = None;
let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen)
let mut last_space: Option<(usize, f64)> = None; // (break position, line width there)
let mut i = start;
let mut brk: Option<(usize, bool, bool)> = None;
while i < n {
let g = &glyphs[i];
if is_forced(g.ch) {
brk = Some((i + 1, false, true));
break;
}
if g.is_space() && !g.no_break && g.break_after != Some(false) {
if sp.justify {
shrink += sp.space_elastic(g).1;
}
last_ok = Some((i, false));
if x <= w + FIT_EPS {
last_natural = last_ok;
}
last_space = Some((i, x));
x += g.adv;
i += 1;
continue;
}
let hang_r = right_hang(g, sp);
let adv = if g.ch == '\t' { tab(line, x, i) } else { g.adv };
let end_shrink = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3)[1];
let available = shrink + sp.box_elastic(g).1.iter().sum::<f64>() + end_shrink;
if sp.kinsoku_priority == 2
&& x + adv - hang_r > w + FIT_EPS
&& let Some((end, hyphen)) = last_natural
{
brk = Some((end, hyphen, false));
break;
}
if x + adv - hang_r > w + available.max(0.0) + FIT_EPS
&& i > start
&& (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break))
{
// Overflow: break at the last opportunity, else before this glyph.
brk = Some(match last_ok {
// Hyphenation zone: break before the word if that leaves little space.
Some((_, true)) if !sp.justify && sp.hyph_zone > 0.0 && last_space.is_some_and(|(_, xs)| w - xs < sp.hyph_zone) => {
(last_space.map_or(i, |s| s.0), false, false)
}
Some((p, h)) => (p, h, false),
None => (emergency_split(glyphs, start, i), false, false),
});
break;
}
x += adv;
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) {
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
{
last_ok = Some((i + 1, false));
if x - hang_r <= w + FIT_EPS {
last_natural = last_ok;
}
} else if hyph_after[i] && may_hyphenate(hyphens) {
let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 };
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
}
}
i += 1;
}
let (end, hyphen, forced) = brk.unwrap_or((n, false, true));
hyphens = if hyphen { hyphens + 1 } else { 0 };
let mut vis_end = end;
while vis_end > start && glyphs[vis_end - 1].is_space() {
vis_end -= 1;
}
let mut next = end;
while next < n && glyphs[next].is_space() {
next += 1;
}
let next = next.max(start + 1).min(n.max(start + 1));
out.push(Break { start, end: vis_end, next: next.min(n), hyphen, forced: forced || end == n });
if next >= n {
break;
}
start = next;
line += 1;
}
// A forced line break as the very last glyph leaves an empty final line (InDesign shows it);
// a column, frame or page break there ends the paragraph.
if let Some(last) = glyphs.last()
&& last.ch == designcraft_doc::story::FORCED_LINE_BREAK
{
out.push(Break { start: n, end: n, next: n, hyphen: false, forced: true });
}
out
}
/// Natural width of a broken line (with its inserted hyphen).
pub fn line_width(glyphs: &[Glyph], b: &Break) -> f64 {
let w: f64 = glyphs[b.start..b.end.max(b.start)].iter().map(|g| g.adv).sum();
w + if b.hyphen && b.end > 0 { glyphs[b.end - 1].size * 0.33 } else { 0.0 }
}
/// Balance Ragged Lines: keep the composer's line count but make the lines as even as possible,
/// by composing against the narrowest measure that still yields that many lines.
pub fn balanced(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
let base = knuth_plass(glyphs, hyph_after, sp, width);
let n = base.len();
if n < 2 || glyphs.len() > 3000 || base[..n - 1].iter().any(|b| b.forced) {
return base;
}
let ok = |f: f64| -> Option<Vec<Break>> {
let w = |j: usize| width(j) * f;
let br = knuth_plass(glyphs, hyph_after, sp, &w);
(br.len() == n && br.iter().enumerate().all(|(j, b)| line_width(glyphs, b) <= w(j) + 0.01)).then_some(br)
};
let (mut lo, mut hi) = (0.3, 1.0);
let mut best = base;
for _ in 0..14 {
let mid = (lo + hi) / 2.0;
match ok(mid) {
Some(b) => {
best = b;
hi = mid;
}
None => lo = mid,
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tiers_are_used_in_order() {
// Word stretch alone.
let (r, ok) = tiered_ratio(5.0, [10.0, 10.0, 10.0], [0.0; 3], 10.0);
assert!(ok && (r - 0.5).abs() < 1e-9);
// Word saturated, half the letter tier.
let (r, _) = tiered_ratio(15.0, [10.0, 10.0, 10.0], [0.0; 3], 10.0);
assert!((r - (1.0 + 0.5 * TIER_COST[1])).abs() < 1e-9);
// Beyond every tier: unbounded word spacing.
let (r, _) = tiered_ratio(40.0, [10.0, 10.0, 10.0], [0.0; 3], 10.0);
assert!(r > 2.0);
// Shrink beyond the tiers is infeasible.
assert!(!tiered_ratio(-25.0, [0.0; 3], [10.0, 10.0, 0.0], 0.0).1);
assert!(tiered_ratio(-15.0, [0.0; 3], [10.0, 10.0, 0.0], 0.0).1);
}
}