Merge #293: Text: a word longer than the line breaks at the column edge; a line that exactly fills the measure fits

Conflict: crates/compose/src/breaker.rs: Item constructors keep next's multi-line form with edge_elastic/start_elastic and add `emergency: false`; kp_pass keeps next's edge_elastic lines, then #293's FIT_EPS clamp and emergency rating; greedy keeps next's tab-aware `adv`, `end_shrink` and `available` with FIT_EPS in the kinsoku_priority == 2 test and the overflow test (`x + adv - hang_r > w + available.max(0.0) + FIT_EPS`); FIT_EPS also added to next's two last_natural checks
Conflict: crates/compose/src/lib.rs: layout_line keeps next's segment-rebased distribute (#284); #293's overfull single-word branch works on line[seg..] (`line.len() > seg + 1 && ... && extra < 0.0`) and comes before next's single-word justification branch
Conflict: crates/compose/src/tests.rs: kept both (#293's tracked_lines and its two tests appended)
This commit is contained in:
aiongg
2026-10-10 06:00:21 +00:00
3 changed files with 141 additions and 10 deletions
+79 -8
View File
@@ -158,6 +158,8 @@ struct Item {
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,
}
const NONE: usize = usize::MAX;
@@ -176,6 +178,7 @@ impl Item {
edge_elastic: [0.0; 2],
start_elastic: [0.0; 2],
zone_from: NONE,
emergency: false,
}
}
fn glue(w: f64, st: f64, sh: f64) -> Item {
@@ -191,6 +194,7 @@ impl Item {
edge_elastic: [0.0; 2],
start_elastic: [0.0; 2],
zone_from: NONE,
emergency: false,
}
}
fn penalty(w: f64, p: f64, flagged: bool) -> Item {
@@ -206,12 +210,22 @@ impl Item {
edge_elastic: [0.0; 2],
start_elastic: [0.0; 2],
zone_from: NONE,
emergency: false,
}
}
}
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
@@ -320,7 +334,8 @@ fn push_inword(it: &mut Vec<Item>, ig: &mut Vec<usize>, 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<Item>, Vec<usize>) {
/// `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();
@@ -408,6 +423,10 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, 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);
}
}
}
@@ -443,7 +462,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;
@@ -452,6 +475,46 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
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;
@@ -527,7 +590,15 @@ fn kp_pass(
}
y[0] = (y[0] + it.edge_elastic[0]).max(0.0);
z[0] = (z[0] + it.edge_elastic[1]).max(0.0);
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 (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 {
@@ -749,7 +820,7 @@ pub fn greedy(
shrink += sp.space_elastic(g).1;
}
last_ok = Some((i, false));
if x <= w {
if x <= w + FIT_EPS {
last_natural = last_ok;
}
last_space = Some((i, x));
@@ -762,13 +833,13 @@ pub fn greedy(
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
&& 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)
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))
{
@@ -790,12 +861,12 @@ pub fn greedy(
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
{
last_ok = Some((i + 1, false));
if x - hang_r <= w {
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 {
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
}
+9 -2
View File
@@ -2348,6 +2348,11 @@ fn layout_line(
let (seg_line, seg_add, seg_scale) =
(line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut []));
distribute(seg_line, &rebased, extra, sp, seg_add, seg_scale);
} else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last && extra < 0.0 {
// An overfull single word shrinks only as far as the minimum letter spacing and glyph scaling allow.
let (seg_line, seg_add, seg_scale) =
(line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut []));
distribute(seg_line, &[], extra, sp, seg_add, seg_scale);
} else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last {
// Single word: Single Word Justification.
match pp.single_word_justify {
@@ -2632,10 +2637,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() {
+53
View File
@@ -4112,3 +4112,56 @@ fn list_first_line_wraps_at_the_column_edge_after_its_label() {
}
}
}
/// 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<Line> {
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_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<usize, f64> =
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;
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::<Vec<_>>());
}
}
}