From 0635dea06548ceb975922b511db0215d5144eecd Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 12 Oct 2026 06:16:04 +0000 Subject: [PATCH 1/2] Text wrap: small single-character frames (tombstones) are set beside text wrap An article's closing tombstone - a one-character text frame, here a 7.6 pt Noto Emoji symbol in a 10.5 pt box - was overset (red +, nothing drawn) whenever its spread had a text-wrap object anywhere, while InDesign sets it. Two causes in the wrap code: - Every text frame took every wrap object on its spread as an exclusion, whether or not the object reached the frame. Now only objects that overlap the frame wrap its text. - free_slot skips gaps narrower than 1.5 em of the paragraph's size (slivers beside a wrapped object), but applied that to the whole column too when nothing cut it: with a 14 pt paragraph style, no line could go in a frame narrower than 21 pt. The minimum now only applies to gaps a wrap object cut; a band no object reaches gets the whole column, however narrow. On the document that showed it (an InDesign 2026 IDML with 12 tombstones and two unrelated wrap objects on the spread), the 12 tombstone stories are set and no other story's layout changes (54 -> 42 overset stories, all of the difference the tombstones). Tests: small_single_character_frame_sets_beside_unrelated_text_wrap and narrow_frame_sets_lines_no_wrap_object_reaches fail before (overset) and pass after. Probably also #476 (Wingdings bullets overset in 20 x 21 pt frames). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 17 ++++++++++++++++- crates/compose/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..5ab809649 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -370,6 +370,13 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let b = other.bounds(); let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]); let inner = inv.transform_rect_bbox(r); + // Only objects that reach the frame wrap its text: one elsewhere on the spread + // leaves it alone (a small frame beside it would otherwise lose its gaps to the + // minimum gap width, see `free_slot`). + let reach = inner.intersect(item.inner_bounds()); + if !(reach.width() > 0.0 && reach.height() > 0.0) { + continue; + } exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode }); } } @@ -2071,9 +2078,13 @@ fn line_metrics( (asc, desc, lead) } -/// Widest free horizontal interval of `col` in the band, or None if blocked. +/// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object +/// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object +/// reaches the band the whole column is free, however narrow (a one-character frame such as an +/// article's closing tombstone). fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; + let mut cut = false; for ex in &f.exclusions { let r = ex.rect; let overlaps_band = r.y0 < y1 && r.y1 > y0; @@ -2081,6 +2092,7 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { + cut = true; let mut next = Vec::new(); for (a, b) in free { if r.x1 <= a || r.x0 >= b { @@ -2099,6 +2111,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f _ => {} } } + if !cut { + return Some((col.x0, col.x1)); + } free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..b0cc474c7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -146,6 +146,44 @@ 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})"); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result. From 1278957455becb2ddb155a62871a4960df842e63 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 12 Oct 2026 15:14:08 +0000 Subject: [PATCH 2/2] Text wrap: the minimum gap applies only where a wrap object reaches the column free_slot held a column to the minimum gap width (1.5 em) whenever a wrap object overlapped the line's band vertically, even if the object sat beside another column of the frame: in a frame of narrow columns, an object beside column 2 dropped column 1's lines. The minimum now applies only when the object reaches the column itself (aiongg's review on #589). Test: wrap_object_beside_one_narrow_column_leaves_the_other_alone (two 18 pt columns in a 14 pt paragraph, an object beside column 2) fails before (overset) and passes after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 8 +++++--- crates/compose/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5ab809649..cc29bee0e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2080,8 +2080,8 @@ fn line_metrics( /// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object /// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object -/// reaches the band the whole column is free, however narrow (a one-character frame such as an -/// article's closing tombstone). +/// reaches the column in the band the whole column is free, however narrow (a one-character frame +/// such as an article's closing tombstone, or a narrow column beside another that wraps). fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; let mut cut = false; @@ -2092,7 +2092,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { - cut = true; + // Held to the minimum width only when the object reaches this column: one beside + // another column of the frame leaves this one whole, however narrow. + cut |= r.x0 < col.x1 && r.x1 > col.x0; let mut next = Vec::new(); for (a, b) in free { if r.x1 <= a || r.x0 >= b { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b0cc474c7..9e5ad3f25 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -184,6 +184,25 @@ fn narrow_frame_sets_lines_no_wrap_object_reaches() { assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})"); } +/// In a frame of narrow columns, a wrap object beside column 2 leaves column 1 alone: only a gap +/// the object cuts in its own column is held to the minimum width (it was dropped, as the object +/// overlapped the line's band, and the text went past column 1). +#[test] +fn wrap_object_beside_one_narrow_column_leaves_the_other_alone() { + // Two 18 pt columns (12 pt gutter): narrower than 1.5 em of the 14 pt paragraph. + let (mut d, sid) = tombstone(Rect::new(300.0, 300.0, 348.0, 340.0), Rect::new(336.0, 300.0, 380.0, 340.0)); + let fid = d.spreads[0].items.iter().find(|i| i.text_frame().is_some_and(|t| t.story == sid)).unwrap().id; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let f = &cs.frames[0]; + assert_eq!(f.columns.len(), 2); + assert!(f.columns[0].width() < 21.0, "column 1 is narrower than 1.5 em: {}", f.columns[0].width()); + assert!(!cs.is_overset()); + let l = &f.lines[0]; + assert_eq!(l.column, 0, "set in column 1"); + assert!(l.baseline - f.columns[0].y0 < 20.0, "on its first line: {}", l.baseline); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result.