Merge #589: (fix)Small frame wrap for overset small text boxes

Conflict: crates/compose/src/lib.rs: next's wrap_obstacles and free_slots kept; frame_specs keeps only exclusions that reach the frame's text area (as wrap.rs does for anchored obstacles), and free_slots applies the 1.5 em minimum segment only when an exclusion reaches the column
Conflict: crates/compose/src/tests.rs: both sides kept; the PR's tombstone tests plus tests for a sliver beside a wrap object in a narrow frame and a narrow first column beside a wrap object in column 2
This commit is contained in:
aiongg
2026-10-12 14:14:12 +00:00
2 changed files with 86 additions and 4 deletions
+17 -4
View File
@@ -604,6 +604,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
}
wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, &mut exclusions);
}
// Only objects that reach the frame's text area wrap its text: one elsewhere on the
// spread would otherwise cost a narrow column its room (the 1.5 em minimum segment of
// `free_slots` applies once an object cuts the column).
let a = item.text_area();
exclusions.retain(|e| {
let r = e.rect;
r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0
});
}
let (page_name, page, left_page) = match (spread_ref, spread) {
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
@@ -2821,12 +2829,14 @@ fn wrap_slot(
/// Free segments of `col` in the band `y0..y1`, in the order text fills them (left to right, or
/// right to left for right-to-left paragraphs); empty if the band is blocked. A line beside an
/// object that lets text through on both sides has a segment on each side. Segments narrower than
/// 1.5 em of `size` are skipped (the model has no minimum segment width). `line_start` is the story
/// byte the line starts at (see [`Exclusion::wraps_line`]).
/// object that lets text through on both sides has a segment on each side. When an object cuts
/// the column, segments narrower than 1.5 em of `size` are skipped (slivers beside it); a column
/// no object reaches is free however narrow (a one-character frame such as an article's closing
/// tombstone). `line_start` is the story byte the line starts at (see [`Exclusion::wraps_line`]).
#[allow(clippy::too_many_arguments)]
fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool, line_start: usize) -> Vec<(f64, f64)> {
let mut free = vec![(col.x0, col.x1)];
let mut cut = false;
for ex in f.exclusions.iter().filter(|e| e.wraps_line(line_start)) {
let r = ex.rect;
let overlaps_band = r.y0 < y1 && r.y1 > y0;
@@ -2839,6 +2849,7 @@ fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool,
_ => None,
};
let Some((a, b)) = blocked.filter(|&(a, b)| a < col.x1 && b > col.x0) else { continue };
cut = true;
// One-sided wraps also block the column on the other side of the object.
let text_left = match ex.side {
WrapSide::LeftSide => Some(true),
@@ -2866,7 +2877,9 @@ fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool,
}
free = next;
}
free.retain(|(a, b)| b - a >= size * 1.5);
if cut {
free.retain(|(a, b)| b - a >= size * 1.5);
}
if rtl {
free.reverse();
}
+69
View File
@@ -146,6 +146,75 @@ fn wrap_pushes_text_aside() {
assert!(below.x0 < 1.0);
}
/// A small frame holding one character (a tombstone at the end of an article: a 7.6 pt symbol in
/// a 10.5 pt box), in a paragraph whose style is 14 pt, with a text-wrap object on the spread at
/// `wrap`.
fn tombstone(frame: Rect, wrap: Rect) -> (Document, StoryId) {
let (mut d, sid, _) = doc_with("X", frame, ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
st.paras[0].chars = designcraft_doc::CharAttrs { size: Some(14.0), ..Default::default() };
st.format_chars(0..1, |f| f.over.size = Some(7.6));
let lid = d.default_layer();
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(wrap));
it.wrap.mode = WrapMode::BoundingBox;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
(d, sid)
}
/// A text-wrap object elsewhere on the spread doesn't affect a frame it doesn't overlap: the small
/// frame's one character is set (it was overset: every frame took every wrap object on the
/// spread, and then no gap narrower than 1.5 em of the paragraph's size was used).
#[test]
fn small_single_character_frame_sets_beside_unrelated_text_wrap() {
let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 310.5), Rect::new(0.0, 0.0, 120.0, 60.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset(), "the tombstone is set");
assert_eq!(cs.line_count(), 1);
}
/// A narrow frame that a text-wrap object overlaps further down sets the lines the object doesn't
/// reach: the minimum gap width only applies to gaps a wrap object cuts.
#[test]
fn narrow_frame_sets_lines_no_wrap_object_reaches() {
let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 340.0), Rect::new(290.0, 330.0, 330.0, 360.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset(), "the first line is above the wrap object");
assert_eq!(cs.line_count(), 1);
let (top, baseline) = (cs.frames[0].columns[0].y0, cs.frames[0].lines[0].baseline);
assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})");
}
/// A wrap object that cuts a narrow frame leaves the sliver beside it (under 1.5 em) empty: no
/// line is set beside the object, and the text continues below it.
#[test]
fn a_wrap_object_cutting_a_narrow_frame_leaves_its_sliver_empty() {
let text = ["of"; 400].join(" ");
let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 60.0, 400.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(6.0));
// An 8 pt sliver at 6 pt: narrower than 1.5 em (9 pt), wide enough for a word.
add_wrap_objects(&mut d, &[(Rect::new(0.0, 50.0, 52.0, 120.0), [0.0; 4], WrapMode::BoundingBox)]);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = &cs.frames[0].lines;
assert!(lines.iter().all(|l| l.baseline + l.descent <= 50.0 + 1e-6 || l.baseline - l.ascent >= 120.0 - 1e-6), "a line beside the object");
assert!(lines.iter().any(|l| l.baseline - l.ascent >= 120.0 - 1e-6), "no text below the object");
}
/// A wrap object beside the second column of a two-column frame leaves the first column, narrower
/// than 1.5 em, set in full.
#[test]
fn a_wrap_object_beside_column_two_leaves_a_narrow_column_one_set() {
let (mut d, sid, fid) = doc_with(&["X"; 12].join(" "), Rect::new(0.0, 0.0, 46.0, 100.0), ParaAttrs::default());
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
o.columns = 2;
o.gutter = 12.0;
// 17 pt columns at 14 pt: narrower than 1.5 em (21 pt).
d.story_mut(sid).unwrap().format_chars(0..23, |f| f.over.size = Some(14.0));
add_wrap_objects(&mut d, &[(Rect::new(35.0, 0.0, 60.0, 100.0), [0.0; 4], WrapMode::BoundingBox)]);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = all_lines(&cs).iter().filter(|l| l.column == 0).count();
assert!(first >= 4, "{first} lines in column 1");
}
/// Insert rectangles with wrap, given as (bounds, [top, left, bottom, right] offsets, mode).
fn add_wrap_objects(d: &mut Document, objects: &[(Rect, [f64; 4], WrapMode)]) {
let lid = d.default_layer();