Text wrap: words stay whole beside wrap objects; a segment too narrow for its word stays empty

This commit is contained in:
aiongg
2026-10-12 11:58:08 +00:00
parent fbda023d7f
commit 7ddd99ee4c
3 changed files with 102 additions and 8 deletions
+28 -2
View File
@@ -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::<f64>();
}
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
+20 -6
View File
@@ -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();
+54
View File
@@ -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)] {