From 7ddd99ee4c1e7873c0dc7ce3bb2c17e587f72097 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 11:58:08 +0000 Subject: [PATCH] Text wrap: words stay whole beside wrap objects; a segment too narrow for its word stays empty --- crates/compose/src/breaker.rs | 30 +++++++++++++++++-- crates/compose/src/lib.rs | 26 +++++++++++++---- crates/compose/src/tests.rs | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 754cfc3d0..c5cfb133f 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -412,8 +412,11 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }]; } 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) { + // A word gets emergency breaks only when no line could hold it: beside a wrap object some + // lines are slivers much narrower than the measure, and a word that fits a full line goes + // there instead of being split. + let widest = (0..=glyphs.len().min(64)).map(width).fold(1.0, f64::max); + if let Some(emergency_after) = overlong_words(&items, &ig, glyphs, widest) { (items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp); } for tolerance in [2.5, 12.0, f64::INFINITY] { @@ -424,6 +427,29 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0) } +/// Width of the first word of `glyphs` after any leading spaces, fully shrunk (the boxes up to +/// the first break opportunity, emergency breaks aside, and the hyphen a break there adds). +pub fn first_word_width(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> f64 { + // A word this long is wider than any line; the rest of the paragraph doesn't matter. + let n = glyphs.len().min(256); + let (items, _) = items(glyphs.get(..n).unwrap_or(glyphs), hyph_after.get(..n).unwrap_or(hyph_after), &[], sp); + let mut w = 0.0; + let mut started = false; + for it in &items { + match it.kind { + Kind::Box => { + started = true; + w += it.w - it.sh.iter().sum::(); + } + Kind::Penalty if it.p >= INF => {} + Kind::Penalty if started => return w + it.w, + Kind::Glue if started => return w, + Kind::Glue | Kind::Penalty => {} + } + } + w +} + /// 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 diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 30b81c7d6..55b119701 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1051,12 +1051,25 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); let reference = cjk_line_reference(line_glyphs); let (mut x0, mut x1) = (col.x0, col.x1); - let continues = !carry.is_empty(); - let mut baseline; - if continues { - // The next segment of a line already placed. - baseline = carry_baseline; - (x0, x1) = carry.remove(0); + // A wrap segment that can't hold the line's first word whole stays empty and the + // text goes on in the next segment or line (a word wider than the column is split + // wherever it lands). + let ind = pp.left_indent + pp.right_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }; + let need = if f.exclusions.is_empty() { + 0.0 + } else { + let w = breaker::first_word_width(glyphs.get(s..).unwrap_or_default(), hyph_after.get(s..).unwrap_or_default(), &spacing); + if w > col.width() - ind { 0.0 } else { w } + }; + let holds = |&(a, b): &(f64, f64)| b - a - ind + 0.01 >= need; + // The line takes the next segment of a line already placed, the one the breaker + // set it for. + let mut continues = false; + let mut baseline = carry_baseline; + if let Some(i) = carry.iter().position(holds) { + continues = i == 0; + (x0, x1) = carry.get(i).copied().unwrap_or((x0, x1)); + carry.drain(..=i); } else { carry.clear(); baseline = cur.next_baseline(f, col, lead, asc, &pp); @@ -1076,6 +1089,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut tries = 0; loop { let segs = free_slots(f, col, baseline - asc, baseline + desc, base_size, rtl); + let segs: Vec<(f64, f64)> = segs.into_iter().skip_while(|seg| !holds(seg)).collect(); if let Some((&first, rest)) = segs.split_first() { (x0, x1) = first; carry = rest.to_vec(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3aaa64645..c5a48558b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -187,6 +187,60 @@ fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() { assert!(!cs.is_overset()); } +/// LOREM beside an ellipse wrapped on both sides along its contour that leaves a sliver (about +/// 20 pt at its middle) between it and the frame's right edge. +fn sliver_wrap(para: ParaAttrs) -> (Document, StoryId, String) { + let text = [LOREM; 4].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 800.0), para); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = + designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Oval, designcraft_geom::shapes::ellipse(Rect::new(150.0, 100.0, 276.0, 220.0))); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::BothSides; + it.wrap.offsets = [4.0; 4]; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + (d, sid, text) +} + +#[test] +fn words_stay_whole_beside_a_wrap_sliver() { + for align in [Align::Left, Align::LeftJustified] { + for hyphenate in [true, false] { + let (d, sid, text) = sliver_wrap(ParaAttrs { align: Some(align), hyphenate: Some(hyphenate), ..Default::default() }); + let ex = &frame_specs(&d, sid)[0].exclusions[0]; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let word = |i: usize| text.as_bytes().get(i).is_some_and(u8::is_ascii_alphanumeric); + let mut empty_slivers = 0; + for (i, l) in lines.iter().enumerate() { + let line = &text[l.range.clone()]; + // A line ends inside a word only at a hyphenation point. + assert!(l.hyphenated || !(word(l.range.end - 1) && word(l.range.end)), "{align:?} {hyphenate}: split word in {line:?}"); + // Every word fits its segment whole. + for g in l.glyphs.iter().filter(|g| g.visible) { + assert!(g.x + g.adv <= l.x1 + 0.5, "{align:?} {hyphenate}: {line:?} overflows its {}..{} segment", l.x0, l.x1); + } + // A left segment beside the object whose sliver stays empty: the next word doesn't fit it. + let Some((_, b)) = ex.contour.as_ref().unwrap().band_extent(l.baseline - l.ascent, l.baseline + l.descent) else { continue }; + let next = lines.get(i + 1); + if l.x1 > b || 300.0 - b < 18.0 || next.is_some_and(|n| n.baseline == l.baseline) { + continue; + } + let Some(next) = next else { continue }; + let first = text[next.range.clone()].split(' ').next().unwrap_or_default(); + let w: f64 = next.glyphs.iter().filter(|g| g.byte < next.range.start + first.len()).map(|g| g.adv).sum(); + assert!(w > 300.0 - b, "{align:?} {hyphenate}: {first:?} ({w}) fits the {} pt sliver beside {line:?}", 300.0 - b); + empty_slivers += 1; + } + if !hyphenate { + assert!(empty_slivers > 0, "{align:?}: no sliver stays empty"); + } + assert!(!cs.is_overset()); + } + } +} + #[test] fn one_sided_wrap_leaves_the_other_side_empty() { for (side, right) in [(WrapSide::RightSide, true), (WrapSide::LeftSide, false), (WrapSide::LargestArea, false)] {