diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 333959626..ab2a9ede7 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3626,6 +3626,8 @@ struct FinishInput<'a> { fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let FinishInput { doc, story, frames, cols, notes, out, boxes, span_paras, split_paras, grid_paras, styles_tab } = input; notes.finish(doc, story, frames, cols, out); + // The story byte of each anchored object's mark, found once for every frame that holds one. + let mut marks: Option> = None; // Ranges, content heights and vertical justification. for (fi, ft) in out.frames.iter_mut().enumerate() { let f = &frames[fi]; @@ -3641,9 +3643,12 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - let anchors: Vec = - if ft.objects.is_empty() { Vec::new() } else { story.text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect() }; - vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras, &anchors); + let anchors: &[usize] = if ft.objects.is_empty() { + &[] + } else { + marks.get_or_insert_with(|| story.text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect()) + }; + vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras, anchors); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -6077,18 +6082,19 @@ fn vertical_justify( None => true, }; // Each row moves no further than a text wrap under it allows. An object that moves with its - // anchor's row (Justify) limits how much further than that row a later row moves. + // anchor's row (Justify) lets a later row move its room plus how far the object 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 + let follow: Vec<(usize, usize, f64, 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)))) + .filter_map(|&(i, a, room, pin)| Some((row_of.get(i).copied().flatten()?, row_of.get(a).copied().flatten()?, room.max(0.0), pin))) .collect(); + // The object moves only once its anchor's row has moved `pin`. 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) + && follow.iter().all(|&(k, m, room, pin)| shift[k] <= room + (shift[m] - pin).max(0.0) + 1e-9) }; // The lowest lines are tested first: they are the ones a narrowing outline stops. let fits = |shift: &[f64]| { @@ -6206,13 +6212,16 @@ fn vertical_justify( /// box. Beside or under a contour, the line moves down only as far as its band stays clear of the /// 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, 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)>) { +/// lines being justified (inline, above the line, or positioned from its anchor) 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, pin), so a line moves down at most the room plus how far the anchor's row moves +/// past `pin`. An object positioned from its anchor and kept within its column moves with its +/// anchor's row until it stops at the column's bottom, so in every mode a line moves down at most +/// the room under it plus the distance the object can move. Held at the column's top, the object +/// moves only after its anchor's row has moved `pin` (0 for the other objects), and until then a +/// line keeps the whole room. 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, 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 @@ -6247,23 +6256,43 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u } } // An own-story anchored object whose anchor is on one of these lines and whose position - // 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)> { + // follows its line moves with the text: the index in `ft.lines` of its anchor's line, how + // much lower the object was when the wrap was taken (an earlier pass placed the object after + // moving the lines), and, for one kept within its column, how far its line moves before the + // object leaves the column's top and how far the object then moves before it stops at the + // column's bottom (none and infinite for the others). + let moves_with_text = |e: &Exclusion| -> Option<(usize, f64, f64, f64)> { let o = *by_anchor.get(&e.after?)?; - 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 l = ft.lines.get(o.line)?; + let now = l.baseline; + let dy = e.anchor_baseline.map_or(0.0, |b| b - now); + match &o.object.position { + designcraft_doc::AnchorPosition::Inline { .. } | designcraft_doc::AnchorPosition::AboveLine { .. } => { + Some((o.line, dy, 0.0, f64::INFINITY)) } - }; - 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))) + designcraft_doc::AnchorPosition::Custom { y_relative, keep_within_column, y_offset, object_point, .. } + if *y_relative == designcraft_doc::anchored::AnchorRelative::Anchor => + { + if !keep_within_column { + return Some((o.line, dy, 0.0, f64::INFINITY)); + } + // Placed as the lines' final positions place it: from its anchor's baseline, + // held between the column's top and bottom. + let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area); + let h = o.size.1; + let ofy = [0.0, 0.5, 1.0][(*object_point / 3).min(2) as usize]; + let (lo, hi) = (col.y0, (col.y1 - h).max(col.y0)); + let place = |b: f64| b + y_offset - h * ofy; + let raw = place(now); + let y = raw.clamp(lo, hi); + let then = e.anchor_baseline.map_or(y, |b| place(b).clamp(lo, hi)); + Some((o.line, then - y, (lo - raw).max(0.0), (hi - y).max(0.0))) + } + designcraft_doc::AnchorPosition::Custom { .. } => None, + } }; // Each wrap's object is looked up once, not for every line. - let moving: Vec> = f.exclusions.iter().map(moves_with_text).collect(); + let moving: Vec> = f.exclusions.iter().map(moves_with_text).collect(); // 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); @@ -6290,13 +6319,15 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u 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, &anchor) in f.exclusions.iter().zip(&moving).filter(|(e, _)| e.wraps_line(start)) { - if anchor.is_some() && !justify { + // Center and Bottom move every row as far: only an object kept within its column, which + // can stop at the column's edges, holds the lines back. + if anchor.is_some_and(|(_, _, _, slack)| slack == f64::INFINITY) && !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 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 }; @@ -6335,7 +6366,18 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u } } match anchor { - Some((a, _)) if room_e.is_finite() => follow.push((line, a, room_e)), + // An object kept within its column moves once its line has moved `pin` (to leave + // the column's top) and stops after moving `slack` (at the column's bottom). + Some((a, _, pin, slack)) if room_e.is_finite() => { + if room_e >= pin { + if justify { + follow.push((line, a, room_e, pin)); + } + room = room.min(room_e + slack); + } else { + room = room.min(room_e); + } + } Some(_) => {} None => room = room.min(room_e), } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ccc71578d..a37729faa 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -890,6 +890,92 @@ fn vertical_justification_stops_at_an_anchored_object_kept_within_its_column() { ); } +/// An object positioned from its anchor and kept within its column moves with its anchor's line +/// until it reaches the column's bottom, so Bottom moves the lines above it down that far, not +/// only as far as where the object starts. +#[test] +fn vertical_justification_moves_an_object_kept_within_its_column_until_it_stops() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify}; + for vj in [Bottom, Center, Justify] { + let (mut d, sid, fid) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj; + // Hung 40 pt under the first baseline, under the second line, and far above the column's + // bottom. + anchor_box(&mut d, sid, 1, 0.0, 40.0, true, WrapMode::BoundingBox); + if let designcraft_doc::AnchorPosition::Custom { keep_within_column, .. } = + &mut std::sync::Arc::make_mut(&mut d.story_mut(sid).unwrap().objects[0]).position + { + *keep_within_column = true; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let o = &cs.frames[0].objects[0]; + let (top, bottom) = (o.origin.y, o.origin.y + 60.0); + let l = lines[1]; + assert!(l.baseline <= top + 1e-6, "{vj:?}: the second line at {} meets the object at {top}..{bottom}", l.baseline); + match vj { + // The object stops at the column's bottom and the second line comes down onto it. + Bottom => { + assert!((bottom - 400.0).abs() < 1e-6, "{vj:?}: the object stopped at {top}..{bottom}, above the column's bottom"); + assert!(l.baseline > top - 0.01, "{vj:?}: the second line stopped at {}, above the object at {top}", l.baseline); + } + // Justify keeps the first line, and the object anchored in it, in place; the second + // line comes down onto the object. + Justify => { + assert!((top - lines[0].baseline - 40.0).abs() < 1e-6, "{vj:?}: the object moved to {top}..{bottom}"); + assert!(l.baseline > top - 0.01, "{vj:?}: the second line stopped at {}, above the object at {top}", l.baseline); + } + // Center moves the lines half of the room under them: the object moves with them. + _ => assert!(bottom < 400.0 - 1e-6 && l.baseline > 150.0, "{vj:?}: second line at {}, object at {top}..{bottom}", l.baseline), + } + } +} + +/// An object anchored in the first line, positioned from its anchor and kept within its column, +/// is held at the column's top. Justify keeps the first line in place, so the object stays there, +/// and the second line comes down onto the wider lower part of its contour. +#[test] +fn vertical_justify_keeps_the_room_under_an_object_held_at_the_columns_top() { + use designcraft_doc::anchored::AnchorRelative as R; + let (mut d, sid, fid) = doc_with("One\nTwo", 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; + // Beside the lines at x 250..300 down to y 100, then x 100..300 down to y 160. + let pts = [ + Point::new(150.0, 0.0), + Point::new(200.0, 0.0), + Point::new(200.0, 160.0), + Point::new(0.0, 160.0), + Point::new(0.0, 100.0), + Point::new(150.0, 100.0), + ]; + let lid = d.default_layer(); + let path = designcraft_geom::path::PathData::single(designcraft_geom::path::SubPath::polyline(&pts, true)); + let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, path); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::BothSides; + // 50 pt above the first baseline, so the column's top holds it. + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: 100.0, + y_offset: -50.0, + object_point: 0, + ref_point: 0, + keep_within_column: true, + spine_relative: false, + }; + d.story_mut(sid).unwrap().insert_object(1, designcraft_doc::AnchoredObject::new(it, position)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 2); + let top = cs.frames[0].objects[0].origin.y; + assert!(lines[0].baseline < 50.0 && top.abs() < 1e-6, "the object is held at the column's top: {top}, {}", lines[0].baseline); + let wide = top + 100.0; + let b = lines[1].baseline; + assert!(b <= wide + 1e-6, "Justify moved the second line to {b}, into the contour's wider part at {wide}"); + assert!(b > wide - 0.01, "Justify stopped the second line at {b}, short of the contour's wider part at {wide}"); +} + /// Justify keeps the first line in place and moves later lines further, so an object anchored in /// the first line and hung under the last one still stops the lines after its anchor. #[test]