diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 2f66eca93..cf1ababb2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7327,6 +7327,44 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the +/// wraps of all those before it; every object still ends up wrapping the lines it hangs over. +#[test] +fn chained_anchored_objects_all_wrap_their_own_storys_lines() { + const OBJECTS: usize = 8; + let text = [LOREM; 12].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 3000.0), ParaAttrs::default()); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&plain); + assert!(lines.len() > 2 + 6 * OBJECTS); + let left = lines[0].x0; + // One object every six lines, each hanging from its anchor's baseline over the lines below; + // inserted last first so the earlier offsets still hold. + let ats: Vec = (0..OBJECTS).map(|k| lines[2 + 6 * k].range.start + 3).collect(); + for &at in ats.iter().rev() { + anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let lines = all_lines(&cs); + let anchors: Vec = d.story(sid).unwrap().text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect(); + let objects = &cs.frames[0].objects; + assert_eq!(objects.len(), OBJECTS); + for o in objects { + let anchor_line = lines.iter().find(|l| l.range.contains(&anchors[o.index])).unwrap(); + assert!((o.origin.y - anchor_line.baseline).abs() < 1e-6, "object {} at {}, anchor line at {}", o.index, o.origin.y, anchor_line.baseline); + let under = lines_in_band(&cs, o.origin.y, o.origin.y + 60.0); + assert!(under.len() >= 2); + assert!( + under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), + "object {} at {}: {:?}", + o.index, + o.origin.y, + under.iter().map(|l| (l.baseline, l.x0)).collect::>() + ); + } +} + #[test] fn anchored_object_without_wrap_leaves_layout_unchanged() { let long = [LOREM; 3].join(" "); diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index e1cdc5fa7..f28bba424 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -12,16 +12,27 @@ //! that come after the line holding its anchor; that line and the lines before it are never //! wrapped by it (an inline object sits in its line and wraps none of its own story). The story //! is composed again with the objects where the previous pass placed them until they stop -//! moving, at most [`MAX_PASSES`] more times. +//! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such +//! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the +//! wraps before it leave its anchor, and the last pass wraps around the last object. The story +//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than +//! [`MAX_PASSES`]. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::Affine; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; -/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving -/// its objects (keep options, vertical justification) stops here. -pub(crate) const MAX_PASSES: usize = 4; +/// Most extra compositions of a story with its own anchored objects' wrap, however many objects +/// it has; a layout that keeps moving its objects (keep options, vertical justification) stops +/// sooner, after one per object and one more (but never fewer than [`MIN_PASSES`]). +pub(crate) const MAX_PASSES: usize = 64; + +/// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single +/// object that keep options or vertical justification move still gets the room it had before. +const MIN_PASSES: usize = 4; +// `clamp` needs min <= max. +const _: () = assert!(MIN_PASSES <= MAX_PASSES); /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -199,8 +210,9 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, if !has_wrapping_objects(st) { return out; } + let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..MAX_PASSES { + for _ in 0..(own + 1).clamp(MIN_PASSES, MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break;