mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
Text wrap: words stay whole beside wrap objects; a segment too narrow for its word stays empty
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
Reference in New Issue
Block a user