diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index bf2c23450..33e55002f 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -97,19 +97,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); @@ -128,7 +150,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) @@ -160,6 +182,8 @@ 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; @@ -179,6 +203,7 @@ impl Item { start_elastic: [0.0; 2], zone_from: NONE, emergency: false, + over: 0.0, } } fn glue(w: f64, st: f64, sh: f64) -> Item { @@ -195,6 +220,7 @@ impl Item { start_elastic: [0.0; 2], zone_from: NONE, emergency: false, + over: 0.0, } } fn penalty(w: f64, p: f64, flagged: bool) -> Item { @@ -211,6 +237,7 @@ impl Item { start_elastic: [0.0; 2], zone_from: NONE, emergency: false, + over: 0.0, } } } @@ -365,6 +392,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S 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); @@ -451,6 +479,7 @@ struct Node { tw: f64, ty: [f64; 3], tz: [f64; 3], + to: f64, demerits: f64, prev: Option, hyphens: u32, @@ -533,12 +562,14 @@ 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]; @@ -554,6 +585,7 @@ fn kp_pass( 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, @@ -593,7 +625,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). @@ -673,7 +705,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]; @@ -686,6 +718,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]; @@ -710,6 +743,7 @@ fn kp_pass( tw, ty, tz, + to, demerits: d, prev: Some(a), hyphens: if flagged { prev.hyphens + 1 } else { 0 }, @@ -942,16 +976,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/lib.rs b/crates/compose/src/lib.rs index ff5c997f1..e6af50ef3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2877,7 +2877,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) @@ -2922,18 +2922,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 f38f60f7f..208d462fe 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -4598,3 +4598,73 @@ 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()]); + } + } +} + +#[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"); +}