From be7a367050c35eb92289815ff3314af8eae489f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 09:01:14 +0000 Subject: [PATCH 1/2] Compose: a tight word-spacing range no longer isolates a word on a justified line Stretch past the maximum word spacing was rated in units of the word spaces' own stretch. With a narrow range (100-105%) that unit is tiny, so a loose line with several word spaces rated far worse than a single word letter-spaced across the measure, and the paragraph composer chose the single word. Rate the overflow in units of at least a third of the word spaces' width (the default range), so word spaces take the extra stretch. --- crates/compose/src/breaker.rs | 70 ++++++++++++++++++++++++++++------- crates/compose/src/tests.rs | 31 ++++++++++++++++ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 754cfc3d0..5327bdf60 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -89,19 +89,41 @@ impl Spacing { } /// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic). fn space_elastic(&self, g: &Glyph) -> (f64, f64) { - if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) { + 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. -pub fn tiered_ratio(d: f64, y: [f64; 3], z: [f64; 3]) -> (f64, bool) { +/// 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); @@ -120,7 +142,7 @@ pub fn tiered_ratio(d: f64, y: [f64; 3], z: [f64; 3]) -> (f64, bool) { } if rem > 1e-6 { // Beyond every maximum: word spaces grow without bound (an H&J violation). - let unit = if cap[0] > EPS { cap[0] } else { cap.iter().sum::() }; + let unit = if over > EPS { over } else { cap.iter().sum::() }; r = if unit > EPS { r + rem / unit } else { INF }; } (r, true) @@ -150,13 +172,15 @@ struct Item { 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, zone_from: NONE, emergency: false } + Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE, emergency: false, over: 0.0 } } fn glue(w: f64, st: f64, sh: f64) -> Item { Item { @@ -170,10 +194,11 @@ impl Item { hang: 0.0, 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, zone_from: NONE, emergency: false } + Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE, emergency: false, over: 0.0 } } } @@ -321,6 +346,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let (st, sh) = sp.space_elastic(g); let mut gl = Item::glue(w, st, sh); gl.hang = prev_hang; + gl.over = sp.space_overflow(g); last_space = it.len(); it.push(gl); ig.push(i); @@ -400,6 +426,7 @@ struct Node { tw: f64, ty: [f64; 3], tz: [f64; 3], + to: f64, demerits: f64, prev: Option, hyphens: u32, @@ -482,20 +509,33 @@ fn kp_pass( 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_hang = items.iter().find(|i| i.kind == Kind::Box).map_or(0.0, |b| b.hang); - let mut arena: Vec = - vec![Node { pos: 0, line: 0, fitness: 1, tw: first_hang, ty: [0.0; 3], tz: [0.0; 3], demerits: 0.0, prev: None, hyphens: 0, flagged: false }]; + let mut arena: Vec = vec![Node { + pos: 0, + line: 0, + fitness: 1, + tw: first_hang, + ty: [0.0; 3], + tz: [0.0; 3], + 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 = vec![0]; @@ -528,7 +568,7 @@ fn kp_pass( // 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); + 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). @@ -603,7 +643,7 @@ fn kp_pass( continue; } // Totals after the break: skip glue and penalties up to the next box. - let (mut tw, mut ty, mut tz) = (sw[b], sy[b], sz[b]); + 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]; @@ -614,6 +654,7 @@ fn kp_pass( } Kind::Glue => { tw += x.w; + to += x.over; for t in 0..3 { ty[t] += x.st[t]; tz[t] += x.sh[t]; @@ -638,6 +679,7 @@ fn kp_pass( tw, ty, tz, + to, demerits: d, prev: Some(a), hyphens: if flagged { prev.hyphens + 1 } else { 0 }, @@ -856,16 +898,16 @@ mod tests { #[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]); + 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]); + 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]); + 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]).1); - assert!(tiered_ratio(-15.0, [0.0; 3], [10.0, 10.0, 0.0]).1); + 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); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..04cf3e447 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2771,3 +2771,34 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +#[test] +fn tight_word_spacing_does_not_isolate_a_word() { + // A narrow word-spacing range (100–105%) with some letter spacing and glyph scaling, no + // hyphenation: lines that need more stretch must open their word spaces, not letter-space a + // single word across the measure. + let para = ParaAttrs { + align: Some(Align::LeftJustified), + hyphenate: Some(false), + first_line_indent: Some(9.0), + word_space_min: Some(1.0), + word_space_desired: Some(1.03), + word_space_max: Some(1.05), + letter_space_min: Some(-0.05), + letter_space_max: Some(0.05), + glyph_scale_min: Some(0.99), + glyph_scale_max: Some(1.01), + ..Default::default() + }; + // Short words: any two fit on a line together. + let text = CORPUS[..2].join(" "); + for w in (200..=320).step_by(5) { + let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, w as f64, 20000.0), para.clone()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + for l in &lines[..lines.len() - 1] { + let words = text[l.range.clone()].split_whitespace().count(); + assert!(words > 1, "width {w}: a single-word line {:?}", &text[l.range.clone()]); + } + } +} From 40c3a78e6e7a251285e0d63942513dc28f5be5e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 09:05:06 +0000 Subject: [PATCH 2/2] Compose: fixed-width spaces keep their width on justified lines Stretch past the maximum word spacing was shared by every space on the line, so em, en and thin spaces widened along with word spaces. Only word spaces take it now; a line without word spaces gives it to its letter gaps, matching how the paragraph composer rates such a line. --- crates/compose/src/lib.rs | 25 +++++++++++++++++++----- crates/compose/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..c5e828884 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2547,7 +2547,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & .iter() .map(|&i| { let g = &line[i]; - if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) { + if !breaker::elastic_space(g) { 0.0 } else if stretch { g.space * (sp.word_max - sp.word_desired).max(0.0) @@ -2592,18 +2592,33 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & let tg = rem.min(yg); rem -= tg; let sign = if stretch { 1.0 } else { -1.0 }; - // Stretch left over beyond every limit is shared equally by the word spaces; shrink never goes - // past the minimums (that would overlap glyphs), so an overfull line stays overfull. - let over = if stretch && !spaces.is_empty() { rem / spaces.len() as f64 } else { 0.0 }; + // Stretch left over beyond every limit is shared equally by the word spaces (fixed-width spaces + // keep their width), else by the letter gaps; shrink never goes past the minimums (that would + // overlap glyphs), so an overfull line stays overfull. + let elastic = spaces.iter().filter(|&&i| breaker::elastic_space(&line[i])).count(); + let gaps = line.iter().enumerate().filter(|&(i, g)| is_box(i, g)).count(); + let (over, over_gap) = match (stretch && rem > 0.0, elastic, gaps) { + (false, _, _) => (0.0, 0.0), + (true, 0, 0) => (0.0, 0.0), + (true, 0, n) => (0.0, rem / n as f64), + (true, n, _) => (rem / n as f64, 0.0), + }; for (k, &i) in spaces.iter().enumerate() { let share = if yw > 1e-9 { tw * word[k] / yw } else { 0.0 }; - add[i] += sign * share + over; + add[i] += sign * share + if breaker::elastic_space(&line[i]) { over } else { 0.0 }; } if yl > 1e-9 && tl > 0.0 { for (i, l) in letter.iter().enumerate() { add[i] += sign * tl * l / yl; } } + if over_gap > 0.0 { + for (i, g) in line.iter().enumerate() { + if is_box(i, g) { + add[i] += over_gap; + } + } + } if yg > 1e-9 && tg > 0.0 { for (i, gcap) in glyph.iter().enumerate() { if *gcap > 0.0 { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 04cf3e447..eaca3026c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2802,3 +2802,42 @@ fn tight_word_spacing_does_not_isolate_a_word() { } } } + +#[test] +fn fixed_width_spaces_do_not_stretch_on_justified_lines() { + // Long words in a narrow column: lines stretch past the maximum word spacing. Only the word + // spaces take that stretch; em, en and thin spaces keep their width. + let words = ["characteristically", "misunderstanding", "unquestionably", "responsibility", "administration", "photographer"]; + let seps = [" ", "\u{2003}", " ", "\u{2002}", " ", "\u{2009}"]; + let text: String = (0..60).map(|i| format!("{}{}", words[i % words.len()], seps[i % seps.len()])).collect(); + let text = text.trim_end().to_string(); + let fixed = |c: char| matches!(c, '\u{2003}' | '\u{2002}' | '\u{2009}'); + // (character, advance) of each fixed space, per line, and whether the line ends the paragraph. + let spaces = |align: Align, w: f64| -> Vec<(Vec<(char, f64)>, bool)> { + let para = ParaAttrs { align: Some(align), hyphenate: Some(false), ..Default::default() }; + let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, w, 20000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs) + .iter() + .map(|l| { + let found = l.glyphs.iter().filter_map(|g| text[g.byte..].chars().next().filter(|&c| fixed(c)).map(|c| (c, g.adv))).collect(); + (found, l.last_in_para) + }) + .collect() + }; + let mut checked = 0; + for w in [190.0, 230.0, 270.0] { + let natural: Vec<(char, f64)> = spaces(Align::Left, w).into_iter().flat_map(|l| l.0).collect(); + for (found, last) in spaces(Align::LeftJustified, w) { + if last { + continue; + } + for (c, a) in found { + let Some(&(_, n)) = natural.iter().find(|x| x.0 == c) else { continue }; + assert!((a - n).abs() < 1e-6, "width {w}: U+{:04X} is {a:.2} wide, not {n:.2}", c as u32); + checked += 1; + } + } + } + assert!(checked > 5, "{checked} fixed spaces on justified lines"); +}