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
This commit is contained in:
aiongg
2026-10-10 09:18:18 +00:00
3 changed files with 134 additions and 15 deletions
+44 -10
View File
@@ -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::<f64>() };
let unit = if over > EPS { over } else { cap.iter().sum::<f64>() };
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<usize>,
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);
}
}
+20 -5
View File
@@ -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 {
+70
View File
@@ -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");
}