diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9eaafdc5d..009ba6c68 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -364,6 +364,9 @@ pub struct Exclusion { /// An anchored object in the frame's own story: it wraps only lines that start after this /// story byte (its anchor). pub after: Option, + /// For an anchored object in the frame's own story: the baseline of the line holding its + /// anchor when the exclusion was taken from it. + pub anchor_baseline: Option, } impl Exclusion { @@ -531,7 +534,7 @@ fn wrap_exclusion( if let Some(custom) = wrap.path.as_ref().and_then(|p| Contour::of(&p.to_bezpath(), inv * xf, 0.0)) { let rect = custom.bounds(); let contour = matches!(wrap.mode, WrapMode::BoundingBox | WrapMode::Contour).then_some(custom); - return Exclusion { rect, mode: wrap.mode, side, contour, after: None }; + return Exclusion { rect, mode: wrap.mode, side, contour, after: None, anchor_baseline: None }; } let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|(path, frame)| { let c = Contour::of(&path, inv * xf, o[0])?; @@ -541,7 +544,7 @@ fn wrap_exclusion( }) }); let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds); - Exclusion { rect, mode: wrap.mode, side, contour, after: None } + Exclusion { rect, mode: wrap.mode, side, contour, after: None, anchor_baseline: None } } /// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space, and @@ -5497,7 +5500,7 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de // Per column (or all lines together). let ncols = if spanned { 1 } else { ft.columns.len().max(1) as u32 }; for c in 0..ncols { - let rooms = wrap_rooms(ft, f, c, spanned, anchors); + let (rooms, follow) = wrap_rooms(ft, f, c, spanned, anchors); // Center and Bottom move the lines together: the nearest text wrap under them is the // bottom of the room they move down into. Justify checks each row's room below. let raised; @@ -5565,7 +5568,20 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de // Each row moves no further than a text wrap under it allows; every row's // shift grows with the space, so the most space that keeps them all clear is // found by bisection. - let clear = |shift: &[f64]| idx.iter().zip(&rows).all(|(&i, &k)| shift[k] <= rooms[i].max(0.0) + 1e-9); + // An object that moves with its anchor's row limits how much further than + // that row a later row moves. + let mut row_of = vec![None; ft.lines.len()]; + for (&i, &k) in idx.iter().zip(&rows) { + row_of[i] = Some(k); + } + let follow: Vec<(usize, usize, f64)> = follow + .iter() + .filter_map(|&(i, a, room)| Some((row_of.get(i).copied().flatten()?, row_of.get(a).copied().flatten()?, room.max(0.0)))) + .collect(); + let clear = |shift: &[f64]| { + idx.iter().zip(&rows).all(|(&i, &k)| shift[k] <= rooms[i].max(0.0) + 1e-9) + && follow.iter().all(|&(k, m, room)| shift[k] - shift[m] <= room + 1e-9) + }; let mut shift = shifts(space); if !clear(&shift) { let (mut lo, mut hi) = (0.0, space); @@ -5627,10 +5643,12 @@ fn wrap_bottom(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, rooms: &[f6 /// contour on its way down (to 0.01 pt). Only wraps that reach into a line's column (or the /// columns it spans) count, as when the lines are set. An anchored object that moves with the /// lines being justified (inline, above the line, or positioned from its anchor and not kept -/// within its column) is skipped, except under Justify. Justify keeps the first row in place, so -/// its room stays infinite. `anchors` holds the story byte of each anchored object's mark, by -/// object index. -fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[usize]) -> Vec { +/// within its column) is skipped, except under Justify, where it moves with the row of its +/// anchor's line: its wrap is taken where the object sits before the lines move, and its rooms go +/// in the second list as (line, anchor's line, room), so a line moves down at most that much +/// further than the anchor's row. Justify keeps the first row in place, so its room stays +/// infinite. `anchors` holds the story byte of each anchored object's mark, by object index. +fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[usize]) -> (Vec, Vec<(usize, usize, f64)>) { const EPS: f64 = 1e-6; // One-sided wraps also block the column on the other side of the object: the x-range wrap // `e` keeps text out of in column `col`, from the object's x-range `a..b` there (None when that @@ -5655,30 +5673,29 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u } }; let in_col = |k: u32| spanned || k == c; + let justify = f.opts.vertical_justification == VerticalJustification::Justify; // An own-story anchored object whose anchor is on one of these lines and whose position - // follows its line moves with the text. Justify moves later lines further than earlier ones, - // so there the object's current top still limits the lines after its anchor. - let moves_with_text = |e: &Exclusion| { - let Some(a) = e.after.filter(|_| f.opts.vertical_justification != VerticalJustification::Justify) else { return false }; + // follows its line moves with the text: the index in `ft.lines` of its anchor's line, and how + // much lower that line was when the wrap was taken (an earlier pass placed the object after + // moving the lines). + let moves_with_text = |e: &Exclusion| -> Option<(usize, f64)> { + let a = e.after?; // The object whose mark is the anchor, placed on one of these lines. - let Some(o) = ft.objects.iter().find(|o| anchors.get(o.index) == Some(&a) && ft.lines.get(o.line).is_some_and(|l| in_col(l.column))) else { - return false; - }; - match &o.object.position { + let o = ft.objects.iter().find(|o| anchors.get(o.index) == Some(&a) && ft.lines.get(o.line).is_some_and(|l| in_col(l.column)))?; + let follows = match &o.object.position { designcraft_doc::AnchorPosition::Inline { .. } | designcraft_doc::AnchorPosition::AboveLine { .. } => true, // Kept within the column, it can stop at the column's edge instead of following. designcraft_doc::AnchorPosition::Custom { y_relative, keep_within_column, .. } => { *y_relative == designcraft_doc::anchored::AnchorRelative::Anchor && !keep_within_column } - } + }; + let now = ft.lines.get(o.line).map_or(0.0, |l| l.baseline); + follows.then(|| (o.line, e.anchor_baseline.map_or(0.0, |b| b - now))) }; // Justify keeps the first row (its segments and a table on them) in place, so wraps under it // set no limit. let first = ft.lines.iter().find(|l| in_col(l.column)).map(|l| l.baseline); - let fixed = |line: usize| { - f.opts.vertical_justification == VerticalJustification::Justify - && ft.lines.get(line).zip(first).is_some_and(|(l, b)| (l.baseline - b).abs() <= 1e-9) - }; + let fixed = |line: usize| justify && ft.lines.get(line).zip(first).is_some_and(|(l, b)| (l.baseline - b).abs() <= 1e-9); // (line, story byte, column, slot x-range, top, bottom) of each line (its band runs from its // top to its baseline) and table fragment. let lines = ft @@ -5689,6 +5706,7 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u .map(|(i, l)| (i, l.range.start, l.column, l.x0, l.x1, l.baseline - l.ascent, l.baseline)); let tables = ft.tables.iter().filter(|t| in_col(t.column)).map(|t| (t.line, t.anchor, t.column, t.rect.x0, t.rect.x1, t.rect.y0, t.rect.y1)); let mut rooms = vec![f64::INFINITY; ft.lines.len()]; + let mut follow = Vec::new(); for (line, start, k, x0, x1, top, bottom) in lines.chain(tables) { let Some(&before) = rooms.get(line) else { continue }; if fixed(line) { @@ -5699,8 +5717,18 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u let col = ft.columns.get(k as usize).copied().unwrap_or(f.area); let col = Rect { x0: col.x0.min(x0), x1: col.x1.max(x1), ..col }; let meets = |(a, b): (f64, f64)| a < x1 - EPS && b > x0 + EPS; - for e in f.exclusions.iter().filter(|e| e.wraps_line(start) && !moves_with_text(e)) { - let r = e.rect; + for e in f.exclusions.iter().filter(|e| e.wraps_line(start)) { + let anchor = moves_with_text(e); + if anchor.is_some() && !justify { + continue; + } + // The wrap where its object sits before the lines move (moved up by `dy`), and how + // far it lets this line move down (relative to its anchor's row for an object that + // moves with the text). + let dy = anchor.map_or(0.0, |(_, dy)| dy); + let cap = if anchor.is_some() { f64::INFINITY } else { room }; + let mut room_e = f64::INFINITY; + let r = Rect { y0: e.rect.y0 - dy, y1: e.rect.y1 - dy, ..e.rect }; if let (WrapMode::BoundingBox | WrapMode::Contour, Some(contour)) = (e.mode, &e.contour) && r.y1 > top && sided(e, col, (r.x0, r.x1)).is_some_and(meets) @@ -5709,8 +5737,8 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u // contour stays inside its box). Moved down by `d`, the line's band sweeps from its // top to its baseline plus `d`; the contour's extent there only grows with `d`, so // the furthest clear move is found by bisection to 0.01 pt. - let hits = |d: f64| contour.band_extent(top, bottom + d).and_then(|x| sided(e, col, x)).is_some_and(meets); - let (mut lo, mut hi) = ((r.y0 - bottom).max(0.0), (r.y1 - top).min(f.area.y1 - bottom).min(room)); + let hits = |d: f64| contour.band_extent(top + dy, bottom + d + dy).and_then(|x| sided(e, col, x)).is_some_and(meets); + let (mut lo, mut hi) = ((r.y0 - bottom).max(0.0), (r.y1 - top).min(f.area.y1 - bottom).min(cap)); if lo < hi && hits(hi) { if hits(lo) { hi = lo; @@ -5726,18 +5754,23 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u lo = mid; } } - room = room.min(lo); + room_e = lo; } } else if r.y0 >= bottom - EPS { // A wrap at or under this line. if blocked(e, col).is_some_and(meets) { - room = room.min(r.y0 - bottom); + room_e = r.y0 - bottom; } } + match anchor { + Some((a, _)) if room_e.is_finite() => follow.push((line, a, room_e)), + Some(_) => {} + None => room = room.min(room_e), + } } rooms[line] = room; } - rooms + (rooms, follow) } // ---------- caret and hit testing (Type tool) ---------- diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 4354aeb47..9d7619c14 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -513,6 +513,7 @@ fn hostile_wrap_geometry_terminates() { side: WrapSide::BothSides, contour: None, after: None, + anchor_baseline: None, }; let f = &mut specs[0]; f.exclusions.push(ex(f64::NAN, f64::NAN, f64::NAN, f64::NAN, WrapMode::JumpObject)); @@ -639,12 +640,12 @@ fn vertical_justification_passes_wraps_that_do_not_stop_the_text() { // Wrap off: the number goes down over the bar. let (lines, area, wrap_top) = bar_chart_lines("42", Bottom, WrapMode::None, WrapSide::BothSides, Rect::new(0.0, 150.0, 60.0, 300.0)); assert!(wrap_top.is_none()); - assert!(lines[0].0 > 150.0 && lines[0].0 <= area.y1 + 1e-6, "{lines:?}"); + assert!((lines[0].0 + lines[0].1 - area.y1).abs() < 1e-6, "{lines:?}, bottom {}", area.y1); // A bar on the right half from the frame's top: the number is set beside it and goes down // beside it. let (lines, area, wrap_top) = bar_chart_lines("42", Bottom, WrapMode::BoundingBox, WrapSide::BothSides, Rect::new(30.0, 0.0, 60.0, 300.0)); assert!(wrap_top.is_some()); - assert!(lines[0].0 > 150.0 && lines[0].0 <= area.y1 + 1e-6, "{lines:?}"); + assert!((lines[0].0 + lines[0].1 - area.y1).abs() < 1e-6, "{lines:?}, bottom {}", area.y1); } /// A one-sided wrap in the second column leaves the first column's lines free to go down, as it @@ -868,6 +869,23 @@ fn vertical_justify_stops_at_an_object_anchored_above_the_lines_it_wraps() { assert!(last > lines[0].baseline + 40.0, "Justify spread the lines: {:?}", lines.iter().map(|l| l.baseline).collect::>()); } +/// Justify moves an object anchored in the second line with that line, so the third line spreads +/// down until it meets the object where it ends up, not where it started. +#[test] +fn vertical_justify_follows_an_object_anchored_in_a_later_line() { + let (mut d, sid, fid) = doc_with("One\nTwo\nSix", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Justify; + anchor_box(&mut d, sid, 5, 0.0, 40.0, true, WrapMode::BoundingBox); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 3); + let baselines = lines.iter().map(|l| l.baseline).collect::>(); + let top = cs.frames[0].objects[0].origin.y; + assert!((top - lines[1].baseline - 40.0).abs() < 1e-6, "the object hangs 40 pt under the second line: {top}, {baselines:?}"); + let last = lines[2].baseline; + assert!((top - last).abs() < 1e-6, "Justify moved the last line to {last}, not down to the object at {top}: {baselines:?}"); +} + /// Same As Clipping follows a placed graphic's clipping path: beside a triangle that widens /// downwards, text reaches further in at the narrow top than at the wide bottom. #[test] diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index f28bba424..a8495bd46 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -46,6 +46,8 @@ pub(crate) struct Obstacle<'a> { xf: Affine, /// Story byte of the anchor, for obstacles in their own story. anchor: Option, + /// Baseline of the line holding the anchor, for obstacles in their own story. + anchor_baseline: Option, } fn wraps(o: &AnchoredObject) -> bool { @@ -126,7 +128,8 @@ pub(crate) fn obstacles<'a>(doc: &Document, story: &'a Story, cs: &ComposedStory } else { None }; - out.push(Obstacle { spread: loc.spread, item: &obj.item, xf, anchor }); + let anchor_baseline = anchor.and_then(|_| ft.lines.get(o.line)).map(|l| l.baseline); + out.push(Obstacle { spread: loc.spread, item: &obj.item, xf, anchor, anchor_baseline }); } } out @@ -157,6 +160,7 @@ pub(crate) fn exclusions(doc: &Document, specs: &[FrameSpec], obs: &[Obstacle]) let r = e.rect; if r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 { e.after = ob.anchor; + e.anchor_baseline = ob.anchor_baseline; ex.push(e); } }