From 88eebe48e8e5aed168e5e269e052efd9012546d4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 05:35:48 +0000 Subject: [PATCH 1/2] Text: a line that exactly fills the measure fits A line whose natural width overruns its measure by less than 0.01 pt (rounded frame widths and advances) fits, in the paragraph and the single-line composer. A one-word line that exactly filled its frame was hyphenated and lost its last letters. --- crates/compose/src/breaker.rs | 14 +++++++++++--- crates/compose/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 3b08e1050..16abde9cf 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -166,6 +166,11 @@ impl Item { 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; + /// 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 @@ -454,7 +459,10 @@ fn kp_pass( y[t] = sy[b][t] - n.ty[t]; z[t] = sz[b][t] - n.tz[t]; } - let (r, feasible) = tiered_ratio(target - l, y, z); + // 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 (r, feasible) = tiered_ratio(d, y, z); if feasible && !forced { keep.push(a); } else { @@ -664,7 +672,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F continue; } let hang_r = right_hang(g, sp); - if x + g.adv - hang_r > w + shrink + if x + g.adv - hang_r > w + shrink + FIT_EPS && i > start && (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break)) { @@ -690,7 +698,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F last_ok = Some((i + 1, false)); } 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 { + if x + hy <= w + shrink + FIT_EPS { last_ok = Some((i + 1, true)); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 256d9a13c..4a500a344 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1889,3 +1889,28 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +/// Lines of `text` set in a frame `width` wide, with `tracking` on every character. +fn tracked_lines(text: &str, width: f64, para: ParaAttrs, tracking: f64) -> Vec { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 4000.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.tracking = Some(tracking)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "overset at width {width}"); + all_lines(&cs).into_iter().cloned().collect() +} + +#[test] +fn a_line_that_exactly_fills_the_measure_fits() { + use designcraft_doc::Composer; + let text = "#knowyourplastic"; + let one = &tracked_lines(text, 1e5, ParaAttrs::default(), 0.0)[0]; + // The measure a rounding error short of the natural width. + let width = one.end_x - one.x0 - 0.005; + for composer in [Composer::Paragraph, Composer::SingleLine] { + for align in [Align::Left, Align::LeftJustified] { + let para = ParaAttrs { composer: Some(composer), align: Some(align), ..Default::default() }; + let lines = tracked_lines(text, width, para, 0.0); + assert_eq!(lines.len(), 1, "{composer:?} {align:?}: {:?}", lines.iter().map(|l| l.range.clone()).collect::>()); + } + } +} From d85ceb92643ffe2a325e8d1559dcfd9ba5a19a57 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 05:39:08 +0000 Subject: [PATCH 2/2] Text: a word longer than the line breaks at the column edge The paragraph composer gets emergency break points inside any word (or part between hyphenation points) that is wider than the narrowest measure even fully shrunk. They cost more than a hyphen, so a legal hyphenation point wins where one fits; justified pieces are rated by the space they leave, so each line takes as many letters as fit. Tracking is part of the width. Justified lines shrink no further than the paragraph's minimum word and letter spacing and glyph scaling: an overfull line stays overfull instead of overlapping its glyphs, and an overfull single word shrinks within the letter and glyph limits. --- crates/compose/src/breaker.rs | 83 ++++++++++++++++++++++++++++++++--- crates/compose/src/lib.rs | 9 +++- crates/compose/src/tests.rs | 28 ++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 16abde9cf..d0a32ab32 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -148,19 +148,32 @@ struct Item { hang: f64, /// 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, } 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 } + Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE, emergency: false } } 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, zone_from: NONE } + 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, + zone_from: NONE, + emergency: false, + } } 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 } + 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 } } } @@ -171,6 +184,10 @@ const INF: f64 = 10000.0; /// 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 @@ -279,7 +296,8 @@ fn push_inword(it: &mut Vec, ig: &mut Vec, p: Item, g: usize, sp: & } /// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens). -fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec) { +/// `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, Vec) { let mut it: Vec = Vec::with_capacity(glyphs.len() * 2 + 2); let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2); let n = glyphs.len(); @@ -357,6 +375,10 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec 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); } } } @@ -389,7 +411,11 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & if glyphs.is_empty() { return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }]; } - let (items, ig) = items(glyphs, hyph_after, sp); + 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; @@ -398,6 +424,46 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & greedy(glyphs, hyph_after, sp, width) } +/// 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> { + let mut out: Option> = 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::(); + } + 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; @@ -462,7 +528,12 @@ 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 (r, feasible) = tiered_ratio(d, y, z); + let (mut r, feasible) = tiered_ratio(d, y, z); + 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 { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3db5070d5..e9a08ad18 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1664,6 +1664,9 @@ fn layout_line( } if (justify_this || squeeze_last) && !spaces.is_empty() { distribute(&line, &spaces, extra, sp, &mut add, &mut scale); + } else if justify_this && spaces.is_empty() && line.len() > 1 && !last && extra < 0.0 { + // An overfull single word shrinks only as far as the minimum letter spacing and glyph scaling allow. + distribute(&line, &[], extra, sp, &mut add, &mut scale); } else if justify_this && spaces.is_empty() && line.len() > 1 && !last { // Single word: Single Word Justification. match pp.single_word_justify { @@ -1940,10 +1943,12 @@ 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 }; for (k, &i) in spaces.iter().enumerate() { let share = if yw > 1e-9 { tw * word[k] / yw } else { 0.0 }; - // Left over beyond every limit: shared equally by the word spaces. - add[i] += sign * (share + rem / spaces.len() as f64); + add[i] += sign * share + over; } if yl > 1e-9 && tl > 0.0 { for (i, l) in letter.iter().enumerate() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 4a500a344..41ce1454c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1899,6 +1899,34 @@ fn tracked_lines(text: &str, width: f64, para: ParaAttrs, tracking: f64) -> Vec< all_lines(&cs).into_iter().cloned().collect() } +#[test] +fn a_word_longer_than_the_line_breaks_at_the_column_edge() { + use designcraft_doc::Composer; + let word = "n".repeat(60); + let long = format!("Then {word} and on"); + for tracking in [0.0, 740.0] { + // Natural advances, from one unbroken line. + let natural: HashMap = + tracked_lines(&long, 1e5, ParaAttrs::default(), tracking)[0].glyphs.iter().map(|g| (g.byte, g.adv)).collect(); + for composer in [Composer::Paragraph, Composer::SingleLine] { + for align in [Align::Left, Align::LeftJustified] { + let para = ParaAttrs { composer: Some(composer), align: Some(align), hyphenate: Some(false), ..Default::default() }; + let lines = tracked_lines(&long, 72.0, para, tracking); + let what = format!("{composer:?} {align:?} tracking {tracking}"); + assert!(lines.len() >= 4, "{what}: {} lines", lines.len()); + for l in &lines { + let ink: Vec<_> = l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).collect(); + let right = ink.iter().map(|g| g.x + g.adv.min(natural[&g.byte])).fold(l.x0, f64::max); + assert!(l.end_x <= l.x1 + 0.02 && right <= l.x1 + 0.02, "{what}: line {:?} ends at {} past {}", l.range, l.end_x, l.x1); + for w in ink.windows(2) { + assert!(w[1].x >= w[0].x + natural[&w[0].byte] - 1e-6, "{what}: glyphs overlap at {}", w[1].byte); + } + } + } + } + } +} + #[test] fn a_line_that_exactly_fills_the_measure_fits() { use designcraft_doc::Composer;