From 0a8e9a5697e48394a07d19cc79c3e63a6127510b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:56:30 -0800 Subject: [PATCH 1/6] Canvas: moving an object with a drop shadow repaints only its area Damage treated any object with an effect as unbounded, so every drag step of a frame with a drop shadow, outer glow, feather or inner effect rendered the whole canvas again. An object's own effects are now bounded: the drop shadow and outer glow by the reach the renderer draws them with, the inner effects and feathers by the shape. Effects on items nested in a group still repaint everything. Strokes are drawn through the item's transform, so the stroke allowance now scales with it, and the strokes of a group's children count toward the group's area. --- crates/render/src/damage.rs | 135 +++++++++++++++++++++++++++-- crates/ui-egui/src/canvas/tests.rs | 28 ++++++ 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index ff76e0ecb..bacd3864c 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -158,36 +158,52 @@ 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 is drawn through the item's transform. The item's own drop shadow and outer glow + // reach `fx::outset` past what it paints; its inner effects and feathers stay inside the shape. + let stroke = it.stroke.extent().max(0.0) * max_scale(it.xf)?; + let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset(it); + Some(painted_bounds(it)?.inflate(pad, pad)) +} + +/// The largest factor `m` stretches a length by (its linear part's largest singular value), or +/// None when that is not finite. +fn max_scale(m: designcraft_geom::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) } /// 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 +400,107 @@ 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"); + } + // 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/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 62064ea79..685c1b205 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2176,6 +2176,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 { From 30c72a032eb78658c6db7582047ec883e35d0186 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:44:46 -0800 Subject: [PATCH 2/6] Compose: vertical justification keeps text out of a wrap under it Center, Bottom and Justify moved the lines down without looking at text wraps, so a number aligned to the bottom of its frame was drawn over a bar whose wrap should have kept it above the bar. The lines now move down only as far as the wraps under them allow. A baseline comes down to the top of a wrap under its slot, and a table fragment stops with its bottom there. Contour wraps are checked along the band each line sweeps through, beside the line as well as under it. Only wraps that reach into a line's column count, as when the lines are set. Center and Bottom move the lines together by the least room; Justify keeps the first row in place and limits each later row by the wrap under it. An anchored object that moves with its line is skipped under Center and Bottom. --- crates/compose/src/lib.rs | 192 ++++++++++++++++++++++- crates/compose/src/tests.rs | 297 +++++++++++++++++++++++++++++++++++- 2 files changed, 480 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index abac72985..9eaafdc5d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3159,7 +3159,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); + 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, &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); @@ -5483,8 +5485,9 @@ 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. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { +/// `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. +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions, anchors: &[usize]) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5494,6 +5497,18 @@ 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); + // 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; + let together = f.opts.vertical_justification != VerticalJustification::Justify; + let f = match together.then(|| wrap_bottom(ft, f, c, spanned, &rooms)).flatten() { + Some(y1) => { + raised = FrameSpec { area: Rect { y1, ..f.area }, ..f.clone() }; + &raised + } + None => f, + }; let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); @@ -5538,11 +5553,31 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de } let gaps = starts.iter().filter(|s| **s).count(); let limit = f.opts.vj_paragraph_spacing_limit.max(0.0); - let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 }; - let per = (space - para * gaps as f64) / n as f64; - let mut shift = vec![0.0; n + 1]; - for k in 1..=n { - shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 }; + let shifts = |space: f64| { + let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 }; + let per = (space - para * gaps as f64) / n as f64; + let mut shift = vec![0.0; n + 1]; + for k in 1..=n { + shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 }; + } + shift + }; + // 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); + let mut shift = shifts(space); + if !clear(&shift) { + let (mut lo, mut hi) = (0.0, space); + for _ in 0..64 { + let mid = (lo + hi) / 2.0; + if clear(&shifts(mid)) { + lo = mid; + } else { + hi = mid; + } + } + shift = shifts(lo); } for (&i, &k) in idx.iter().zip(&rows) { ft.lines[i].baseline += shift[k]; @@ -5564,6 +5599,147 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de } } +/// The bottom of the room Center and Bottom move the lines of column `c` (every column when +/// `spanned`) down into, when a text wrap under them sits above the text area's bottom (None +/// otherwise): the bottom the lines are aligned by (the lowest descent or table bottom) plus the +/// least of `rooms` (from [`wrap_rooms`]), so the text moves down by that room and no further. +fn wrap_bottom(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, rooms: &[f64]) -> Option { + let in_col = |k: u32| spanned || k == c; + let lines = ft.lines.iter().zip(rooms).filter(|(l, _)| in_col(l.column)); + let room = lines.clone().map(|(_, r)| *r).fold(f64::INFINITY, f64::min); + let bottom = lines + .map(|(l, _)| l.baseline + l.descent) + .chain(ft.tables.iter().filter(|t| in_col(t.column)).map(|t| t.rect.y1)) + .fold(f64::NEG_INFINITY, f64::max); + if !room.is_finite() || !bottom.is_finite() { + return None; + } + let y1 = bottom + room.max(0.0); + (y1 < f.area.y1).then_some(y1) +} + +/// 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. 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 { + 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; + // 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 }; + // 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 { + 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 + } + } + }; + // 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) + }; + // (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()]; + 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 in f.exclusions.iter().filter(|e| e.wraps_line(start) && !moves_with_text(e)) { + let r = 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, 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)); + 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 = room.min(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); + } + } + } + rooms[line] = room; + } + rooms +} + // ---------- 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 f15f1e33d..4354aeb47 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -573,6 +573,301 @@ 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 > 150.0 && lines[0].0 <= area.y1 + 1e-6, "{lines:?}"); + // 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:?}"); +} + +/// 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 + l.descent - 200.0).abs() < 1e-6, "baseline {}, descent {}", l.baseline, l.descent); +} + +/// 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 + last.descent - 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::>()); +} + /// 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] @@ -2291,7 +2586,7 @@ fn vertical_justification_counts_table_fragment_bottoms() { let mut spec = frame_specs(&d, sid).remove(0); spec.opts.vertical_justification = mode; - vertical_justify(&mut ft, &spec, false, &d.footnote_options); + vertical_justify(&mut ft, &spec, false, &d.footnote_options, &[]); let moved = ft.lines[anchor].baseline - baseline; let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; From 61428f05d9b4981755dfdee2ef99c627d3b666b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:15:31 -0800 Subject: [PATCH 3/6] Compose: Justify keeps later lines out of an object anchored in an earlier line An object anchored in a line that Justify moves goes down with that line. The lines after it now stop where the object ends up, so the last line no longer runs into it. The Bottom tests also check that a wrap that does not stop the text leaves the last line on the text area's bottom. --- crates/compose/src/lib.rs | 89 +++++++++++++++++++++++++------------ crates/compose/src/tests.rs | 22 ++++++++- crates/compose/src/wrap.rs | 6 ++- 3 files changed, 86 insertions(+), 31 deletions(-) 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); } } From f0f609a3ba9c1e5b8ed14dacf634e96500036e7f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:55:00 -0800 Subject: [PATCH 4/6] Canvas: a scaled object's hardened shadow or glow stays in its damage The spread of a drop shadow or outer glow is stroked through the item's transform, so on a scaled item it reaches that much farther. Damage now grows the effect outset by the item's largest scale factor, and the multithreaded renderer crops the effect's offscreen image to the scaled reach, so the outer part of the glow is no longer cut off. --- crates/render/src/damage.rs | 42 +++++++++++++++-------- crates/render/src/fx.rs | 68 ++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index bacd3864c..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) @@ -167,23 +169,18 @@ fn item_area(it: &Item) -> Option { if !ok { return None; } - // The stroke is drawn through the item's transform. The item's own drop shadow and outer glow - // reach `fx::outset` past what it paints; its inner effects and feathers stay inside the shape. - let stroke = it.stroke.extent().max(0.0) * max_scale(it.xf)?; - let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset(it); + // 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)) } -/// The largest factor `m` stretches a length by (its linear part's largest singular value), or -/// None when that is not finite. -fn max_scale(m: designcraft_geom::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) -} - /// 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. /// Its children's bounds include their strokes. None when a child's transform is not finite. @@ -427,6 +424,23 @@ mod tests { 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(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c7c91fe1..26a87dacd 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -35,17 +35,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) +} + /// (blur σ, growth) in points for an effect `size` with `pct` percent hardened. fn split(size: f64, pct: f64) -> (f64, f64) { let size = bound(size).max(0.0); @@ -104,7 +124,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 @@ -125,7 +147,7 @@ impl Renderer { let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // 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 { @@ -607,4 +629,42 @@ mod tests { // The transparent half shows the page: no shadow painted there. assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); } + + /// 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}"); + } + } } From a8656662e9ec091ee1e4ac4c7d85f10ebffab19b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:29:14 -0800 Subject: [PATCH 5/6] Compose: vertical justification looks up each wrap's anchored object once The object anchored at each story byte is indexed once per column, and each wrap's object is found once, not once for every line. --- crates/compose/src/lib.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9c38b29a5..4891b41a1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -5691,14 +5691,20 @@ 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; + // 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 a = e.after?; - // The object whose mark is the anchor, placed on one of these lines. - 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 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. @@ -5709,6 +5715,8 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u 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); @@ -5734,8 +5742,7 @@ 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)) { - let anchor = moves_with_text(e); + for (e, &anchor) in f.exclusions.iter().zip(&moving).filter(|(e, _)| e.wraps_line(start)) { if anchor.is_some() && !justify { continue; } From 6d8f26a6beff4c0c5e63e5acf5b5678baefdd69d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:48:49 -0800 Subject: [PATCH 6/6] Render: cull a scaled object by its scaled stroke and glow Culling grew an object's box by its unscaled stroke and effect outset, so a view or repaint patch that showed only the outer part of a scaled object's stroke or hardened glow skipped the object. Culling now scales both by the object's largest scale factor, as drawing does. The CMYK ink-pass area grows each object, and each nested child, by its stroke and effect outset under its full transform. --- crates/render/src/blendspace.rs | 45 +++++++++++++++++++++++++++++++-- crates/render/src/draw.rs | 7 +++-- crates/render/src/fx.rs | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) 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/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 0fc1ba048..89f193d06 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -1576,4 +1576,48 @@ mod tests { 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}"); + } + } + } }