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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa
This commit is contained in:
Sean Hammond
2026-10-12 06:16:04 +00:00
co-authored by Claude Opus 5.5
parent 835edae659
commit 0635dea065
2 changed files with 54 additions and 1 deletions
+16 -1
View File
@@ -370,6 +370,13 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
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)))
}
+38
View File
@@ -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.