Merge pull request #1949 from garrytan/fix/settle-chained-own-story-wraps

compose: settle a story's own anchored wraps by object count, not 4 passes
This commit is contained in:
Brandon Thomas
2026-10-29 21:53:10 -04:00
committed by GitHub
2 changed files with 55 additions and 5 deletions
+38
View File
@@ -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<usize> = (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<usize> = 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::<Vec<_>>()
);
}
}
#[test]
fn anchored_object_without_wrap_leaves_layout_unchanged() {
let long = [LOREM; 3].join(" ");
+17 -5
View File
@@ -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;