diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89883b78a..b83f479fe 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -365,6 +365,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 { @@ -897,7 +900,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(|src| { let mut c = Contour::of(&src.path, inv * xf, o[0])?; @@ -910,7 +913,7 @@ fn wrap_exclusion( Some(c) }); 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 follows, in the frame's inner space, with what clips it. @@ -3635,7 +3638,9 @@ 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)); - vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras); + 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); 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); @@ -5961,9 +5966,17 @@ fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tin out } -/// `spanned`: a paragraph spans the frame's columns, so the lines move together. `grid_aligns`: -/// each paragraph's Align to Baseline Grid setting. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions, grid_aligns: &[GridAlign]) { +/// `spanned`: a paragraph spans the frame's columns, so the lines move together. `anchors`: the +/// story byte of each anchored object's mark, by object index. +/// `grid_aligns`: each paragraph's Align to Baseline Grid setting. +fn vertical_justify( + ft: &mut FrameText, + f: &FrameSpec, + spanned: bool, + notes: &designcraft_doc::FootnoteOptions, + grid_aligns: &[GridAlign], + anchors: &[usize], +) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5973,6 +5986,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, follow) = wrap_rooms(ft, f, c, spanned, anchors); let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); if idx.is_empty() { continue; @@ -5994,6 +6008,13 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de // `space`: the room under the bottom of the text. let inside = f.vertical || note_top <= f.area.y1; let space = if inside { limit - ink_bottom } else { limit - last_baseline }; + // Center and Bottom move the lines together: the nearest text wrap under them is the + // bottom of the room they move down into, so the room is the least any line has before a + // wrap stops it. Justify checks each row's room below. + let space = match f.opts.vertical_justification { + VerticalJustification::Justify => space, + _ => idx.iter().map(|&i| rooms[i]).fold(space, f64::min), + }; if space <= 0.0 { continue; } @@ -6050,16 +6071,31 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de } 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. + 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) + }; // The lowest lines are tested first: they are the ones a narrowing outline stops. let fits = |shift: &[f64]| { idx.iter().zip(&rows).rev().all(|(&i, &k)| { let l = &ft.lines[i]; shift[k] <= 0.0 || ((k == n || l.baseline + l.descent + shift[k] <= limit + 1e-9) && in_outline(l, k == n, shift[k])) - }) + }) && clear(shift) }; // In a rectangular frame fit decreases monotonically as the shift grows. A shaped // frame can narrow and widen again, so scan from the largest shift before refining the - // first fitting interval. This also bounds the room used for baseline-grid quantization. + // first fitting interval. This also bounds the room used for baseline-grid quantization + // and preserves clearance from fixed and anchor-following wraps. let most_room = |top: f64| -> f64 { if fits(&shifts(top)) { return top; @@ -6140,6 +6176,152 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de } } +/// How far each line of column `c` (every column when `spanned`) may move down before a text +/// wrap stops it, by index in `ft.lines` (infinite without one); a table fragment's room counts +/// for its line. No line moves into a wrap under its slot. As when lines are set around a wrap, a +/// line's band runs from its top to its baseline, so a baseline may come down to a wrap's top; a +/// table fragment stops with its bottom there. A wrap without a contour counts by its bounding +/// 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)>) { + 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 + // range misses the column). + let sided = |e: &Exclusion, col: Rect, (a, b): (f64, f64)| -> Option<(f64, f64)> { + let r = e.rect; + (a < col.x1 && b > col.x0).then_some(match e.side { + WrapSide::LeftSide => (a, f64::INFINITY), + WrapSide::RightSide => (f64::NEG_INFINITY, b), + WrapSide::LargestArea if r.x0 - col.x0 >= col.x1 - r.x1 => (a, f64::INFINITY), + WrapSide::LargestArea => (f64::NEG_INFINITY, b), + WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => (a, b), + }) + }; + // The x-range a wrap under the text keeps it out of in column `col`; None when text may stay beside it. + let blocked = |e: &Exclusion, col: Rect| -> Option<(f64, f64)> { + let r = e.rect; + match e.mode { + WrapMode::None => None, + WrapMode::JumpObject | WrapMode::JumpToNextColumn => (r.x0 < col.x1 && r.x1 > col.x0).then_some((f64::NEG_INFINITY, f64::INFINITY)), + WrapMode::BoundingBox | WrapMode::Contour => sided(e, col, (r.x0, r.x1)), + } + }; + let in_col = |k: u32| spanned || k == c; + let justify = f.opts.vertical_justification == VerticalJustification::Justify; + // The object whose mark is at each story byte, placed on one of these lines (the first one + // listed). + let mut by_anchor = HashMap::new(); + for o in ft.objects.iter().filter(|o| ft.lines.get(o.line).is_some_and(|l| in_col(l.column))) { + if let Some(&a) = anchors.get(o.index) { + by_anchor.entry(a).or_insert(o); + } + } + // 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)> { + 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 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))) + }; + // Each wrap's object is looked up once, not for every line. + 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); + 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 + .lines + .iter() + .enumerate() + .filter(|(_, l)| in_col(l.column)) + .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) { + continue; + } + let mut room = before; + // A line that spans columns reaches past its own column. + 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, &anchor) in f.exclusions.iter().zip(&moving).filter(|(e, _)| e.wraps_line(start)) { + 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) + { + // A contour beside or under this line whose box reaches the line's slot (the + // 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 + 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; + } + for _ in 0..64 { + if hi - lo <= 0.01 { + break; + } + let mid = (lo + hi) / 2.0; + if hits(mid) { + hi = mid; + } else { + lo = mid; + } + } + 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_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, follow) +} + // ---------- caret and hit testing (Type tool) ---------- /// Two stacked glyphs this far apart in `y` sit on different rows (warichu). diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 107e5f5d2..d2f92f2ee 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)); @@ -573,6 +574,318 @@ fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() { assert!(!cs.is_overset()); } +/// A 60 × 200 pt frame at the origin holding `text` with vertical justification `vj`, and a bar +/// at `bar` whose text wrap `wrap` keeps 1 mm above it: (baseline, descent) of each line, the +/// text area and the wrap's top (None when the bar doesn't wrap the frame). +fn bar_chart_lines( + text: &str, + vj: designcraft_doc::VerticalJustification, + wrap: WrapMode, + side: WrapSide, + bar: Rect, +) -> (Vec<(f64, f64)>, Rect, Option) { + let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj; + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(bar)); + it.wrap.mode = wrap; + it.wrap.side = side; + it.wrap.offsets = [72.0 / 25.4, 0.0, 0.0, 0.0]; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let spec = frame_specs(&d, sid).remove(0); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let lines = all_lines(&cs).iter().map(|l| (l.baseline, l.descent)).collect(); + (lines, spec.area, spec.exclusions.first().map(|e| e.rect.y0)) +} + +/// Bottom, Center and Justify keep the lines out of a text wrap under them: a number aligned to +/// the bottom of its frame sits on the wrap's top offset over a bar instead of on the bar (#1831), +/// with every wrap mode that stops text there. +#[test] +fn vertical_justification_stops_at_a_wrap_under_the_text() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify, Top}; + let bar = Rect::new(0.0, 150.0, 60.0, 300.0); + for wrap in [WrapMode::BoundingBox, WrapMode::Contour, WrapMode::JumpObject, WrapMode::JumpToNextColumn] { + let run = |vj, text| bar_chart_lines(text, vj, wrap, WrapSide::BothSides, bar); + let (top, _, wrap_top) = run(Top, "42"); + let wrap_top = wrap_top.unwrap(); + assert!((wrap_top - (150.0 - 72.0 / 25.4)).abs() < 1e-6, "{wrap:?}: the wrap's top is 1 mm above the bar: {wrap_top}"); + + let (bottom, _, _) = run(Bottom, "42"); + let b = bottom[0].0; + assert!((b - wrap_top).abs() < 1e-6, "{wrap:?}: Bottom put the baseline at {b}, not on the wrap's top at {wrap_top}"); + assert!(b > top[0].0 + 50.0, "{wrap:?}: Bottom moved the line down: {b}, {}", top[0].0); + + // Center takes half the room Bottom takes. + let (center, _, _) = run(Center, "42"); + assert!((center[0].0 - top[0].0 - (b - top[0].0) / 2.0).abs() < 1e-6, "{wrap:?}: Center at {}, Bottom at {b}", center[0].0); + + // Justify keeps the first baseline and puts the last where Bottom puts it. + let (top2, _, _) = run(Top, "12\n34"); + let (bottom2, _, _) = run(Bottom, "12\n34"); + let (justify, _, _) = run(Justify, "12\n34"); + assert_eq!(justify.len(), 2); + assert!((justify[0].0 - top2[0].0).abs() < 1e-6, "{wrap:?}: Justify moved the first line: {justify:?}"); + assert!((justify[1].0 - bottom2[1].0).abs() < 1e-6, "{wrap:?}: Justify {justify:?}, Bottom {bottom2:?}"); + assert!(justify[1].0 <= wrap_top + 1e-6); + } +} + +/// Without a wrap, or beside one, Bottom still moves the text down to the text area's bottom. +#[test] +fn vertical_justification_passes_wraps_that_do_not_stop_the_text() { + use designcraft_doc::VerticalJustification::Bottom; + // 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 - 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 - 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 +/// leaves them when they are set. +#[test] +fn vertical_justification_passes_a_one_sided_wrap_in_another_column() { + let (mut d, sid, fid) = doc_with("One", Rect::new(0.0, 0.0, 400.0, 200.0), ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.columns = 2; + o.vertical_justification = designcraft_doc::VerticalJustification::Bottom; + let lid = d.default_layer(); + let mut it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(250.0, 100.0, 350.0, 300.0)), + ); + it.wrap.mode = WrapMode::BoundingBox; + it.wrap.side = WrapSide::RightSide; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + assert_eq!(l.column, 0); + assert!((l.baseline - 200.0).abs() < 1e-6, "baseline {}", l.baseline); +} + +/// Beside a contour that widens further down, Bottom moves a line only as far as its band stays +/// clear of the contour. +#[test] +fn vertical_justification_stops_where_a_contour_beside_the_text_widens() { + use designcraft_doc::VerticalJustification::{Bottom, Top}; + // Right edge fixed at 60, left edge x = 55 − 50y/190 from y = 0 down to y = 190. + let edge = |y: f64| 55.0 - 50.0 * y / 190.0; + let pts = [Point::new(55.0, 0.0), Point::new(60.0, 0.0), Point::new(60.0, 190.0), Point::new(5.0, 190.0)]; + let run = |vj| { + let (mut d, sid, fid) = doc_with("42", Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj; + 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; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let l = all_lines(&cs)[0]; + (l.baseline, l.x1) + }; + let (top, x1) = run(Top); + assert!(x1 <= edge(top) + 1e-6, "Top sets the line clear of the contour: x1 {x1}, edge {}", edge(top)); + let (bottom, x1) = run(Bottom); + assert!(bottom >= top - 1e-6 && bottom < 190.0, "Bottom at {bottom}, Top at {top}"); + assert!(x1 <= edge(bottom) + 1e-6, "Bottom moved the line into the contour: baseline {bottom}, x1 {x1}, edge {}", edge(bottom)); +} + +/// Under a contour whose box starts beside the column and which widens into it further down, +/// Bottom moves a line down to where the contour meets its slot, not to the top of the box. +#[test] +fn vertical_justification_stops_where_a_contour_under_the_text_meets_it() { + // Left edge x = 70 − 40(y − 100)/90 from y = 100 down to y = 190: it crosses the column's + // right edge (60) at y = 122.5. + let pts = [Point::new(70.0, 100.0), Point::new(80.0, 100.0), Point::new(80.0, 190.0), Point::new(30.0, 190.0)]; + let (mut d, sid, fid) = doc_with("42", Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom; + 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; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let b = all_lines(&cs)[0].baseline; + assert!(b <= 122.5 + 1e-6, "Bottom moved the baseline to {b}, past where the contour meets the column at 122.5"); + assert!(b > 122.0, "Bottom stopped the line at {b}, short of where the contour meets the column at 122.5"); +} + +/// An object anchored in the text and positioned from its anchor moves down with the lines, so +/// Center places lines above it as it places them without its wrap. +#[test] +fn vertical_justification_passes_an_anchored_object_that_moves_with_the_text() { + let baselines = |wrap| { + 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::Center; + // Hung 40 pt under the first line's baseline, under the second line. + anchor_box(&mut d, sid, 1, 0.0, 40.0, true, wrap); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().map(|l| l.baseline).collect::>() + }; + let plain = baselines(WrapMode::None); + let wrapped = baselines(WrapMode::BoundingBox); + assert_eq!(plain.len(), 2); + assert!(plain[0] > 150.0, "Center moved the lines down: {plain:?}"); + assert_eq!(plain.len(), wrapped.len()); + assert!(plain.iter().zip(&wrapped).all(|(a, b)| (a - b).abs() < 1e-6), "without the wrap {plain:?}, with it {wrapped:?}"); +} + +/// Justify keeps the first line in place, so a wrap between it and the next line does not stop +/// the later lines from spreading down to the text area's bottom. +#[test] +fn vertical_justify_passes_a_wrap_under_the_first_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; + let lid = d.default_layer(); + let mut it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 20.0, 300.0, 40.0)), + ); + it.wrap.mode = WrapMode::JumpObject; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 3); + assert!( + lines[0].baseline < 20.0 && lines[1].baseline - lines[1].ascent >= 40.0 - 1e-6, + "{:?}", + lines.iter().map(|l| l.baseline).collect::>() + ); + let last = lines[2]; + assert!((last.baseline - 400.0).abs() < 1e-6, "{:?}", lines.iter().map(|l| l.baseline).collect::>()); +} + +/// Justify moves each row by its own share of the space, so a wrap between the second and third +/// rows limits the second row's move, not the third's. +#[test] +fn vertical_justify_limits_each_row_by_the_wrap_under_it() { + use designcraft_doc::VerticalJustification::{Justify, Top}; + let para = ParaAttrs { space_before: Some(40.0), ..Default::default() }; + let run = |vj, bar: Option| { + let (mut d, sid, fid) = doc_with("One\nTwo\nSix", Rect::new(0.0, 0.0, 300.0, 400.0), para.clone()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj; + if let Some(bar) = bar { + let lid = d.default_layer(); + let mut it = + designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(bar)); + it.wrap.mode = WrapMode::JumpObject; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().map(|l| (l.baseline, l.ascent, l.descent)).collect::>() + }; + let top = run(Top, None); + assert_eq!(top.len(), 3); + let b2 = top[1].0; + let (b3, a3, _) = top[2]; + // A bar 20 pt under the second baseline, clear of the third line. + let bar = Rect::new(0.0, b2 + 20.0, 300.0, b2 + 30.0); + assert!(b3 - a3 > bar.y1, "the third line starts under the bar: {top:?}"); + let set = run(Top, Some(bar)); + assert!(set.iter().zip(&top).all(|(a, b)| (a.0 - b.0).abs() < 1e-6), "the bar moves no line: {set:?}"); + let justify = run(Justify, Some(bar)); + assert!((justify[0].0 - top[0].0).abs() < 1e-6, "{justify:?}"); + // The second row's baseline comes down to the bar; the third row moves twice as far. + let moved = justify[1].0 - b2; + assert!((moved - 20.0).abs() < 1e-6, "the second row moved {moved}: {justify:?}"); + assert!((justify[2].0 - b3 - 2.0 * moved).abs() < 1e-6, "{justify:?}"); +} + +/// Each anchored object's own position decides whether its wrap moves with the text: a fixed +/// object without a wrap on the same line leaves the moving one skipped. +#[test] +fn vertical_justification_matches_each_wrap_to_its_anchored_object() { + let baselines = |wrap| { + 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::Center; + // A fixed object from the frame's top, then one hung 40 pt under the first line. + anchor_box(&mut d, sid, 1, 150.0, 0.0, false, WrapMode::None); + anchor_box(&mut d, sid, 1 + OBJECT_LEN, 0.0, 40.0, true, wrap); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(cs.frames[0].objects.len(), 2); + all_lines(&cs).iter().map(|l| l.baseline).collect::>() + }; + let plain = baselines(WrapMode::None); + let wrapped = baselines(WrapMode::BoundingBox); + assert!(plain[0] > 150.0, "Center moved the lines down: {plain:?}"); + assert_eq!(plain.len(), wrapped.len()); + assert!(plain.iter().zip(&wrapped).all(|(a, b)| (a - b).abs() < 1e-6), "without the wrap {plain:?}, with it {wrapped:?}"); +} + +/// An object positioned from its anchor but kept within its column can stop at the column's +/// bottom, so Bottom does not move the lines after its anchor into it. +#[test] +fn vertical_justification_stops_at_an_anchored_object_kept_within_its_column() { + 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::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); + assert!(bottom <= 400.0 + 1e-6, "the object stays in the column: {top}..{bottom}"); + let l = lines[1]; + assert!( + l.baseline <= top + 1e-6 || l.baseline - l.ascent >= bottom - 1e-6, + "the second line at {} meets the object at {top}..{bottom}", + l.baseline + ); +} + +/// 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] +fn vertical_justify_stops_at_an_object_anchored_above_the_lines_it_wraps() { + 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, 1, 0.0, 50.0, true, WrapMode::BoundingBox); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 3); + let top = cs.frames[0].objects[0].origin.y; + assert!((top - lines[0].baseline - 50.0).abs() < 1e-6, "the object hangs 50 pt under the first line: {top}, {}", lines[0].baseline); + let last = lines[2].baseline; + assert!(last <= top + 1e-6, "Justify moved the last line to {last}, into the object at {top}"); + 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:?}"); +} + /// An Alpha Channel contour counts only where the graphic's clipping path shows the image: here /// the visible pixels are in the right half and the clipping path shows the left half, so the /// image wraps nothing; inverted, the clipping path shows the right half and the image wraps. @@ -2660,6 +2973,69 @@ fn vertical_justification_keeps_lines_inside_a_shaped_frame() { assert!((last.baseline - 260.0).abs() < 1e-3, "Bottom: last baseline at {}", last.baseline); } +/// In a shaped frame with a text wrap under the text, Center, Bottom and Justify keep the lines +/// inside the outline and out of the wrap together: in a triangle that widens downward, Bottom and +/// Justify put the last baseline on the wrap's top and Center moves half as far; in one that +/// narrows downward, the outline stops the lines above the wrap, where they stop without it. +#[test] +fn vertical_justification_keeps_a_shaped_frame_and_a_wrap_under_the_text_together() { + use designcraft_doc::VerticalJustification::{self, Bottom, Center, Justify, Top}; + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + let compose_with = |tri: [Point; 3], mode: VerticalJustification, bar: bool| { + let (mut d, sid, fid) = doc_with("Typography\ngypsy", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + it.path = PathData::single(SubPath { anchors: tri.iter().map(|&p| Anchor::corner(p)).collect(), closed: true }); + let o = &mut it.text_frame_mut().unwrap().options; + o.vertical_justification = mode; + o.inset = [0.0; 4]; + if bar { + let lid = d.default_layer(); + let mut it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 250.0, 300.0, 400.0)), + ); + it.wrap.mode = WrapMode::JumpObject; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "{mode:?}"); + let lines: Vec = all_lines(&cs).into_iter().cloned().collect(); + assert!(lines.len() >= 2, "{mode:?}: {lines:?}"); + lines + }; + let up = [Point::new(150.0, 0.0), Point::new(300.0, 300.0), Point::new(0.0, 300.0)]; + let top = compose_with(up, Top, true); + let top_last = top[top.len() - 1].baseline; + for mode in [Bottom, Justify] { + let lines = compose_with(up, mode, true); + let last = lines[lines.len() - 1].baseline; + assert!((last - 250.0).abs() < 1e-3, "{mode:?}: last baseline at {last}, wrap top at 250: {lines:?}"); + for (k, l) in lines.iter().enumerate() { + let bottom = if k + 1 == lines.len() { l.baseline } else { l.baseline + l.descent }; + let top = l.baseline - l.ascent; + for (x, y) in [(l.x0, top), (l.x1, top), (l.x0, bottom), (l.x1, bottom)] { + assert!((x - 150.0).abs() <= y / 2.0 + 1e-3, "{mode:?}: line {k} corner ({x}, {y}) outside the triangle: {lines:?}"); + } + } + } + let center = compose_with(up, Center, true); + let center_last = center[center.len() - 1].baseline; + assert!((center_last - (top_last + 250.0) / 2.0).abs() < 1e-3, "Center: last baseline at {center_last}, Top at {top_last}"); + + let down = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(150.0, 300.0)]; + for mode in [Center, Bottom, Justify] { + let with = compose_with(down, mode, true); + let without = compose_with(down, mode, false); + assert!(without[without.len() - 1].baseline < 250.0, "{mode:?}: {without:?}"); + assert_eq!(with.len(), without.len()); + for (a, b) in with.iter().zip(&without) { + assert!((a.baseline - b.baseline).abs() < 1e-3, "{mode:?}: {with:?} with the wrap, {without:?} without"); + } + } +} + #[test] fn table_rows_clear_footnotes_and_continue_in_the_next_column() { for span in [false, true] { diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index cdb3ff7c0..1424d9dd5 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -47,6 +47,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, } /// The wrap may be set on the frame or on its placed graphic (Direct Selection). @@ -128,7 +130,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 @@ -159,6 +162,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); } } diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 6096b3419..5dd8232a5 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -371,10 +371,18 @@ impl Renderer { } } // What an object can paint on the spread: its bounds with its (and its children's) - // strokes and effects. + // strokes and effects, which are drawn through each one's transform and grow with it. + fn outset(it: &Item, outer: Affine, o: &mut f64) { + let xf = outer * it.xf; + let scale = crate::fx::max_scale(xf).unwrap_or(1.0); + *o = o.max(crate::fx::outset_scaled(it, scale) + it.stroke.extent() * scale); + for c in it.children() { + outset(c, xf, o); + } + } let reach = |it: &Item| { let mut o: f64 = 0.0; - it.walk(&mut |c| o = o.max(crate::fx::outset(c) + c.stroke.extent())); + outset(it, Affine::IDENTITY, &mut o); it.bounds().inflate(o + 2.0, o + 2.0) }; let items: Vec<(Rect, bool)> = sp.items.iter().filter(|it| visible(it)).map(|it| (reach(it), transparent(it))).collect(); @@ -601,6 +609,39 @@ mod tests { assert_eq!(draw(&doc, &mut renderer).pixels, rgb.pixels); } + /// A hardened outer glow is stroked through the object's transform, and through its group's, + /// so the ink-pass area grows by the scaled growth: the square at 30..60 glows out to -30. + #[test] + fn cmyk_area_holds_a_scaled_objects_glow() { + for nested in [false, true] { + let mut doc = document(); + let mut item = Item::new( + ItemId(doc.alloc()), + doc.default_layer(), + Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), + ); + item.fill = Fill::swatch("C=100 M=0 Y=0 K=0"); + let g = &mut item.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + if nested { + let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); + group.xf = Affine::scale(3.0); + group.content = Content::Group { items: vec![Arc::new(item)] }; + doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + } else { + item.xf = Affine::scale(3.0); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + let cache = Cache::new(); + let opts = RenderOptions::default(); + let f = + Frame { doc: &doc, cache: &cache, view: Affine::IDENTITY, visible: Rect::new(-1e4, -1e4, 1e4, 1e4), px: 1.0, opts: &opts, mt: false }; + let area = Renderer::new().transparent_area(&f, SpreadRef::Doc(0)).unwrap(); + assert!(area.x0 <= -30.0, "nested {nested}: the area {area:?} holds the glow out to -30"); + } + } + #[test] fn cmyk_pass_count_is_constant_and_unchanged_views_are_cached() { let mut doc = document(); diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index ff76e0ecb..aa1780014 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -11,6 +11,8 @@ use std::sync::Arc; use designcraft_doc::{Document, Item, ItemId, SpreadRef, Story, StoryId}; use designcraft_geom::Rect; +use crate::fx::max_scale; + /// Changed regions in spread coordinates, or `None` when the whole view must be repainted. pub fn damage(old: &Document, new: &Document) -> Option> { damage_with(old, new, None) @@ -158,36 +160,47 @@ fn shows_other_text(d: &Document, s: &Story) -> bool { fn item_area(it: &Item) -> Option { let mut ok = true; it.walk(&mut |i| { - // Effects spill outside; text wrap reflows other frames. - if i.effects.any() || i.has_wrap() { + // Text wrap reflows other frames. A nested item's effects are drawn through its parents' + // transforms, which can scale how far they reach. + if i.has_wrap() || !std::ptr::eq(i, it) && i.effects.any() { ok = false; } }); if !ok { return None; } - let pad = it.stroke.extent().max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 }; - Some(painted_bounds(it).inflate(pad, pad)) + // The stroke and the effects' spread are drawn through the item's transform. The item's own + // drop shadow and outer glow reach `fx::outset_scaled` past what it paints; its inner effects + // and feathers stay inside the shape. + let scale = max_scale(it.xf)?; + let stroke = it.stroke.extent().max(0.0) * scale; + let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset_scaled(it, scale); + if !pad.is_finite() { + return None; + } + Some(painted_bounds(it)?.inflate(pad, pad)) } /// An item's bounds, grown to its text: text is composed in the box around the frame's path in /// the frame's own coordinates, which a sheared then rotated frame turns past its path's bounds. -fn painted_bounds(it: &Item) -> Rect { +/// Its children's bounds include their strokes. None when a child's transform is not finite. +fn painted_bounds(it: &Item) -> Option { let mut b = it.bounds(); if it.is_text_frame() { b = b.union(it.xf.transform_rect_bbox(it.inner_bounds())); } for c in it.children() { - b = b.union(it.xf.transform_rect_bbox(painted_bounds(c))); + let stroke = c.stroke.extent().max(0.0) * max_scale(c.xf)?; + b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)?.inflate(stroke, stroke))); } - b + Some(b) } /// A text frame's area: its spread and bounds with room for glyph overhang. fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> { let loc = d.find(f)?; let it = d.item_at(&loc)?; - let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)); + let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)?); // Overhang of italics, swashes and big initials, and anchored objects pinned to lines. Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0))) } @@ -384,4 +397,124 @@ mod tests { let spreads: Vec = dmg.iter().map(|(r, _)| *r).collect(); assert!(spreads.contains(&SpreadRef::Doc(1)), "{dmg:?}"); } + + /// Moving an object with a drop shadow or an outer glow damages where its effect was and is, + /// and every pixel the move changes lies in that damage: the canvas repaints those regions, + /// not the whole view. + #[test] + fn moving_an_object_with_a_shadow_or_glow_damages_its_effect_area() { + for glow in [false, true] { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0))); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + if glow { + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 30.0, 20.0); + } else { + let s = &mut it.effects.drop_shadow; + (s.on, s.color, s.opacity, s.size, s.distance, s.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 25.0, 30.0); + } + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let old = d.clone(); + let it = d.item_mut(id).unwrap(); + it.path.transform(designcraft_geom::Affine::translate((60.0, 40.0))); + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| !Rect::new(98.0, 98.0, 312.0, 242.0).contains(*p)), "glow {glow}: the effect paints outside the object"); + } + // A hardened glow on a scaled object: its growth is stroked through the transform, so it + // reaches three times as far on the spread as the glow's size says. + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0))); + it.xf = designcraft_geom::Affine::scale(3.0); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let old = d.clone(); + let it = d.item_mut(id).unwrap(); + it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf; + // The square covers 150..270 before the move; its glow reaches 60 pt past that. + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| p.x < 110.0), "the hard glow grows with the object's scale"); + // An effect on an object inside a group is drawn through the group's transform: unbounded. + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let mut child = + Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 9.0, 9.0))); + child.effects.drop_shadow.on = true; + let gid = ItemId(d.alloc()); + let mut group = Item::new(gid, lid, designcraft_doc::Shape::Group, Default::default()); + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] }; + d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let old = d.clone(); + d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::scale(4.0); + assert!(damage(&old, &d).is_none()); + } + + /// A stroke is drawn through its item's transform: a frame scaled up paints its stroke that + /// much wider, and moving it damages all of it. + #[test] + fn moving_a_scaled_frame_damages_its_whole_stroke() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 125.0, 100.0))); + it.xf = designcraft_geom::Affine::scale(2.0); + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.weight = 20.0; + it.stroke.align = designcraft_doc::StrokeAlign::Outside; + it.effects.outer_glow.on = true; + it.effects.outer_glow.size = 0.0; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let old = d.clone(); + let it = d.item_mut(id).unwrap(); + it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf; + // The stroke reaches 40 pt past the frame's 100..250 × 100..200 outline on the spread. + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| p.x < 70.0), "the stroke paints its scaled width"); + // The same frame inside a group with a gradient feather: the child's stroke counts too. + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let mut child = old.spreads[0].items[0].as_ref().clone(); + child.effects = Default::default(); + child.id = ItemId(d.alloc()); + let gid = ItemId(d.alloc()); + let outline = designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0)); + let mut group = Item::new(gid, d.default_layer(), designcraft_doc::Shape::Group, outline); + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] }; + let gf = &mut group.effects.gradient_feather; + (gf.on, gf.start, gf.end) = (true, 1.0, 0.5); + d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let old = d.clone(); + d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::translate((60.0, 40.0)); + let changed = changed_pixels_in_damage(&old, &d); + assert!(changed.iter().any(|p| p.x < 70.0), "the child's stroke paints its scaled width"); + } + + /// The pixels a page render changes from `old` to `new`, each checked to lie in the damage. + fn changed_pixels_in_damage(old: &Document, new: &Document) -> Vec { + let dmg = damage(old, new).expect("bounded damage"); + let cache = designcraft_compose::Cache::new(); + let mut r = crate::Renderer::new(); + r.threads = 0; + let opts = crate::RenderOptions::default(); + let before = r.render_page(old, &cache, 0, 1.0, false, &opts).unwrap(); + let after = r.render_page(new, &cache, 0, 1.0, false, &opts).unwrap(); + let mut changed = Vec::new(); + for y in 0..before.height { + for x in 0..before.width { + if before.pixel(x, y) == after.pixel(x, y) { + continue; + } + let p = designcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + assert!(dmg.iter().any(|(_, r)| r.inflate(1.0, 1.0).contains(p)), "changed pixel {p:?} outside {dmg:?}"); + changed.push(p); + } + } + changed + } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 625338eb0..dbaa1d1b3 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -179,7 +179,10 @@ impl Renderer { return; } let xf = parent * it.xf; - let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0); + // The stroke and the effects' growth are drawn through `xf`, which stretches them. + let scale = fx::max_scale(xf).unwrap_or(1.0); + let stroke = it.stroke.extent() * scale; + let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(stroke + 2.0, stroke + 2.0); // A frame with items pasted into it draws like any other object (draw_body: fill, the items // clipped to the frame, stroke), so its effects apply to all of it. if !it.children().is_empty() && !it.has_nested_items() { @@ -193,7 +196,7 @@ impl Renderer { _ => 0.0, }; let effects = it.effects.any() && f.opts.quality != DisplayQuality::Fast; - let fx_outset = if effects { fx::outset(it) } else { 0.0 }; + let fx_outset = if effects { fx::outset_scaled(it, scale) } else { 0.0 }; if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) { return; } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 65cb4a66e..89f193d06 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -40,17 +40,37 @@ pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 { /// How far (points) the effects of `it` reach outside its geometry. pub(crate) fn outset(it: &Item) -> f64 { + outset_scaled(it, 1.0) +} + +/// [`outset`] for an item whose transform stretches lengths by up to `scale`: the blur and the +/// shadow's distance are spread lengths, but the spread's growth is stroked through the item's +/// transform and grows with it. +pub(crate) fn outset_scaled(it: &Item, scale: f64) -> f64 { let e = &it.effects; + let extra = |size: f64, pct: f64| split(size, pct).1 * (scale - 1.0).max(0.0); let mut o: f64 = 0.0; if e.drop_shadow.on { - o = o.max(bound(e.drop_shadow.distance).abs() + bound(e.drop_shadow.size).abs() * 1.6 + 1.0); + let s = &e.drop_shadow; + o = o.max(bound(s.distance).abs() + bound(s.size).abs() * 1.6 + 1.0 + extra(s.size, s.spread)); } if e.outer_glow.on { - o = o.max(bound(e.outer_glow.size).abs() * 1.6 + 1.0); + let g = &e.outer_glow; + o = o.max(bound(g.size).abs() * 1.6 + 1.0 + extra(g.size, g.spread)); } o } +/// The largest factor `m` stretches a length by (its linear part's largest singular value), or +/// None when that is not finite. +pub(crate) fn max_scale(m: Affine) -> Option { + let [a, b, c, d, _, _] = m.as_coeffs(); + let s = a * a + b * b + c * c + d * d; + let det = a * d - b * c; + let v = ((s + (s * s - 4.0 * det * det).max(0.0).sqrt()) / 2.0).sqrt(); + v.is_finite().then_some(v) +} + /// Whether `it` has a basic feather: a width on a closed shape. fn basic_feather(it: &Item) -> bool { it.effects.feather > 0.0 && it.path.is_closed() @@ -120,7 +140,9 @@ impl Renderer { pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) { let doc = f.doc; let e = &it.effects; - let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() }; + // The stroke and the effects' growth are drawn through `xf`, which stretches them. + let scale = max_scale(xf).unwrap_or(1.0); + let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() * scale }; // Spread-space reach of what the object paints. let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); // The silhouette is the shape when the object paints its area (fill / image); text frames @@ -150,7 +172,7 @@ impl Renderer { let filter = shadow(sigma, paint); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. - let r = (reach + off).inflate(grow, grow); + let r = (reach + off).inflate(grow * scale, grow * scale); // The effect's blend sees what's behind the object: a layer around the filtered one. let mix = crate::blend_mode(mode).mix; if mix != Mix::Normal { @@ -1516,4 +1538,86 @@ mod tests { } } } + + /// A hardened outer glow is stroked through the item's transform, so a scaled item's glow + /// grows with it. The multithreaded renderer draws the glow offscreen, cropped to its reach, + /// and that reach scales too. + #[test] + fn a_scaled_items_hard_glow_is_not_cropped() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)), + ); + it.xf = Affine::scale(3.0); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + // max_scale's own cases: a plain scale, a rotation and a shear. + assert_eq!(max_scale(it.xf), Some(3.0)); + assert!((max_scale(Affine::rotate(0.7)).unwrap() - 1.0).abs() < 1e-12); + assert!((max_scale(Affine::skew(1.0, 0.0)).unwrap() - (1.0 + 5f64.sqrt()) / 2.0).abs() < 1e-12); + assert_eq!(max_scale(Affine::scale(f64::INFINITY)), None); + // The 20 pt growth is 60 pt on the spread: the glow covers 90..330. + assert_eq!(outset_scaled(&it, 3.0), outset(&it) + 40.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = crate::Cache::new(); + for threads in [0, 2] { + let mut r = Renderer::new(); + r.threads = threads; + let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap(); + let p = page.pixel(100, 210); + let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + assert!(lum < 200, "{threads} threads: the glow 50 pt left of the scaled square is drawn, luminance {lum}"); + } + } + + /// Culling measures a scaled item's stroke and hardened glow as they are drawn, through its + /// transform, so a view (or a repaint patch) that shows only that part still draws it. + #[test] + fn a_scaled_items_hard_glow_and_stroke_are_not_culled() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let square = |glow: bool| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)), + ); + it.xf = Affine::scale(3.0); + it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK); + if glow { + let g = &mut it.effects.outer_glow; + (g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0); + } else { + (it.stroke.swatch, it.stroke.weight) = (designcraft_color::swatch::BLACK.into(), 20.0); + } + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d + }; + let cache = crate::Cache::new(); + let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + // The square covers 150..270 on the spread. Its glow grows 60 pt (to 90), past the + // unscaled 33 pt reach; its 20 pt stroke is 60 pt wide (to 120), past the unscaled 22 pt. + for (glow, x) in [(true, 95.0), (false, 123.0)] { + let d = square(glow); + for threads in [0, 2] { + let mut r = Renderer::new(); + r.threads = threads; + let place = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let image = r.render(&d, &cache, &place, (4, 20), Affine::translate((-x, -200.0)), &opts); + let p = image.pixel(1, 10); + let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + assert!(lum < 200, "glow {glow}, {threads} threads: the view at x {x} shows the scaled square's edge, luminance {lum}"); + } + } + } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 02a63a299..3e9040050 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2420,6 +2420,34 @@ fn edits_patch_the_canvas_only_at_full_resolution() { } } +/// Moving a frame with a drop shadow repaints the regions the frame and its shadow cover, as +/// moving one without a shadow does, instead of rendering the whole canvas again on every step. +#[test] +fn moving_a_frame_with_a_drop_shadow_patches_the_canvas() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let frame = |app: &mut DesignApp| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 180], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + app.run("object.dropShadow", json!({"on": true})).unwrap(); + for _ in 0..3 { + frame(&mut app); + } + for step in 1..=3 { + app.run("transform.move", json!({"dx": 4, "dy": 2})).unwrap(); + frame(&mut app); + assert_eq!(app.canvas.patches, step, "drag step {step} repaints only what changed"); + } +} + /// The Type tool's caret at the end of a new text frame holding `text`, the app's first frames /// drawn; returns the story id. fn typing_app(app: &mut DesignApp, ctx: &egui::Context, text: &str) -> Value {