From 254cc49d1bed644da6943c831755724245b6e5cf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 17:04:48 +0000 Subject: [PATCH] Text wrap: an image's contour stops at its frame --- crates/compose/src/lib.rs | 163 +++++++++++++++++++++++------------- crates/compose/src/tests.rs | 33 ++++++++ 2 files changed, 138 insertions(+), 58 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e9f532375..5ade72645 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -301,6 +301,9 @@ pub struct Exclusion { #[derive(Clone, Debug, PartialEq)] pub struct Contour { pub rings: Vec>, + /// The frame path of an outline taken from a placed graphic: the frame clips the graphic, so + /// the outline counts only within it (empty: no clip). + pub clip: Vec>, pub offset: f64, } @@ -310,65 +313,105 @@ const MAX_CONTOUR_POINTS: usize = 8192; impl Contour { /// The outline of `path` (after `to_inner`), flattened; None if it is empty or too detailed. fn of(path: &designcraft_geom::BezPath, to_inner: designcraft_geom::Affine, offset: f64) -> Option { - let mut rings: Vec> = Vec::new(); - let mut n = 0usize; - let mut too_many = false; - designcraft_geom::kurbo::flatten(to_inner * path.clone(), 0.25, |el| { - n += 1; - too_many |= n > MAX_CONTOUR_POINTS; - match el { - designcraft_geom::PathEl::MoveTo(p) => rings.push(vec![p]), - designcraft_geom::PathEl::LineTo(p) => match rings.last_mut() { - Some(r) => r.push(p), - None => rings.push(vec![p]), - }, - _ => {} - } - }); - rings.retain(|r| r.len() >= 2 && r.iter().all(|p| p.x.is_finite() && p.y.is_finite())); - (!too_many && !rings.is_empty() && offset.is_finite()).then_some(Contour { rings, offset: offset.max(0.0) }) + let rings = flatten_rings(path, to_inner)?; + offset.is_finite().then_some(Contour { rings, clip: Vec::new(), offset: offset.max(0.0) }) + } + + /// This outline counted only within `frame` (after `to_inner`); unclipped if the frame is + /// empty or too detailed. + fn within(self, frame: &designcraft_geom::BezPath, to_inner: designcraft_geom::Affine) -> Contour { + match flatten_rings(frame, to_inner) { + Some(clip) => Contour { clip, ..self }, + None => self, + } + } + + /// The outline mapped by `a`. + fn transformed(self, a: designcraft_geom::Affine) -> Contour { + let map = |rings: Vec>| rings.into_iter().map(|r| r.into_iter().map(|p| a * p).collect()).collect(); + Contour { rings: map(self.rings), clip: map(self.clip), offset: self.offset } } fn bounds(&self) -> Rect { - let mut pts = self.rings.iter().flatten(); - let Some(&p0) = pts.next() else { return Rect::ZERO }; - let r = pts.fold(Rect::from_points(p0, p0), |r, &p| r.union_pt(p)); + let Some(r) = rings_bounds(&self.rings) else { return Rect::ZERO }; + let r = match rings_bounds(&self.clip) { + Some(c) => r.intersect(c), + None => r, + }; r.inflate(self.offset, self.offset) } /// Horizontal extent of the outline (offset included) within the band `y0..y1`: the band is - /// widened by the offset, and the outline's x-range there is widened by it too. + /// widened by the offset, the outline's x-range there is limited to the clip's x-range there, + /// then widened by the offset too. fn band_extent(&self, y0: f64, y1: f64) -> Option<(f64, f64)> { let (y0, y1) = (y0 - self.offset, y1 + self.offset); - let mut lo = f64::INFINITY; - let mut hi = f64::NEG_INFINITY; - for ring in &self.rings { - let Some(&last) = ring.last() else { continue }; - let mut p = last; - for &q in ring { - let (a, b) = (p, q); - p = q; - if a.y.max(b.y) < y0 || a.y.min(b.y) > y1 { - continue; - } - let dy = b.y - a.y; - let (t0, t1) = if dy.abs() < 1e-12 { - (0.0, 1.0) - } else { - let (ta, tb) = ((y0 - a.y) / dy, (y1 - a.y) / dy); - (ta.min(tb).clamp(0.0, 1.0), ta.max(tb).clamp(0.0, 1.0)) - }; - for t in [t0, t1] { - let x = a.x + t * (b.x - a.x); - lo = lo.min(x); - hi = hi.max(x); - } - } + let (mut lo, mut hi) = rings_band_extent(&self.rings, y0, y1)?; + if !self.clip.is_empty() { + let (a, b) = rings_band_extent(&self.clip, y0, y1)?; + (lo, hi) = (lo.max(a), hi.min(b)); } (lo <= hi).then_some((lo - self.offset, hi + self.offset)) } } +/// The closed polylines of `path` (after `to_inner`), flattened; None if it is empty or too detailed. +fn flatten_rings(path: &designcraft_geom::BezPath, to_inner: designcraft_geom::Affine) -> Option>> { + let mut rings: Vec> = Vec::new(); + let mut n = 0usize; + let mut too_many = false; + designcraft_geom::kurbo::flatten(to_inner * path.clone(), 0.25, |el| { + n += 1; + too_many |= n > MAX_CONTOUR_POINTS; + match el { + designcraft_geom::PathEl::MoveTo(p) => rings.push(vec![p]), + designcraft_geom::PathEl::LineTo(p) => match rings.last_mut() { + Some(r) => r.push(p), + None => rings.push(vec![p]), + }, + _ => {} + } + }); + rings.retain(|r| r.len() >= 2 && r.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + (!too_many && !rings.is_empty()).then_some(rings) +} + +fn rings_bounds(rings: &[Vec]) -> Option { + let mut pts = rings.iter().flatten(); + let &p0 = pts.next()?; + Some(pts.fold(Rect::from_points(p0, p0), |r, &p| r.union_pt(p))) +} + +/// Horizontal extent of closed polylines within the band `y0..y1`. +fn rings_band_extent(rings: &[Vec], y0: f64, y1: f64) -> Option<(f64, f64)> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for ring in rings { + let Some(&last) = ring.last() else { continue }; + let mut p = last; + for &q in ring { + let (a, b) = (p, q); + p = q; + if a.y.max(b.y) < y0 || a.y.min(b.y) > y1 { + continue; + } + let dy = b.y - a.y; + let (t0, t1) = if dy.abs() < 1e-12 { + (0.0, 1.0) + } else { + let (ta, tb) = ((y0 - a.y) / dy, (y1 - a.y) / dy); + (ta.min(tb).clamp(0.0, 1.0), ta.max(tb).clamp(0.0, 1.0)) + }; + for t in [t0, t1] { + let x = a.x + t * (b.x - a.x); + lo = lo.min(x); + hi = hi.max(x); + } + } + } + (lo <= hi).then_some((lo, hi)) +} + /// The wrap exclusion of `other` (an item whose inner space maps to the spread by `xf`) for a /// frame whose inner space is reached from the spread by `inv`. `left_page` places the object on /// a left page (for the spine sides). @@ -387,16 +430,24 @@ pub fn exclusion(other: &designcraft_doc::Item, xf: designcraft_geom::Affine, in WrapSide::AwayFromSpine => WrapSide::RightSide, s => s, }; - let contour = (other.wrap.mode == WrapMode::Contour && other.shape != designcraft_doc::Shape::Group) - .then(|| contour_path(other)) - .and_then(|path| Contour::of(&path, inv * xf, other.wrap.offsets[0])); + let contour = (other.wrap.mode == WrapMode::Contour && other.shape != designcraft_doc::Shape::Group).then(|| contour_path(other)).and_then( + |(path, frame)| { + let c = Contour::of(&path, inv * xf, other.wrap.offsets[0])?; + Some(match frame { + Some(frame) => c.within(&frame, inv * xf), + None => c, + }) + }, + ); let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds); Exclusion { rect, mode: other.wrap.mode, side, contour } } -/// The outline a contour wrap follows, in the item's inner space. Graphic frames follow their contour type; detected edges, alpha channels and image paths need -/// the image, so they follow the frame's path (as other frames do). -fn contour_path(it: &designcraft_doc::Item) -> designcraft_geom::BezPath { +/// The outline a contour wrap follows, in the item's inner space, and for an outline taken from +/// the placed graphic, the frame path that clips it. Graphic frames follow their contour type; +/// detected edges, alpha channels and image paths need the image, so they follow the frame's path +/// (as other frames do). +fn contour_path(it: &designcraft_doc::Item) -> (designcraft_geom::BezPath, Option) { let frame = designcraft_geom::corners::apply(&it.path, &it.corners); if it.wrap.contour == designcraft_doc::ContourType::BoundingBox && let Some(g) = it.graphic() @@ -405,10 +456,10 @@ fn contour_path(it: &designcraft_doc::Item) -> designcraft_geom::BezPath { let gb = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)); let r = gb.intersect(fb); if r.width() > 0.0 && r.height() > 0.0 { - return designcraft_geom::kurbo::Shape::to_path(&r, 0.1); + return (designcraft_geom::kurbo::Shape::to_path(&r, 0.1), Some(frame)); } } - frame + (frame, None) } /// One frame of the thread, ready for composition. @@ -547,11 +598,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let v = designcraft_doc::vertical_text_xf(area).inverse(); let ex = exclusions .into_iter() - .map(|e| Exclusion { - rect: v.transform_rect_bbox(e.rect), - contour: e.contour.map(|c| Contour { rings: c.rings.iter().map(|r| r.iter().map(|&p| v * p).collect()).collect(), ..c }), - ..e - }) + .map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), contour: e.contour.map(|c| c.transformed(v)), ..e }) .collect(); let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b))); (Rect::new(0.0, 0.0, area.height(), area.width()), ex, None, pr) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index c5d2149ee..7cb0858f5 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -304,6 +304,39 @@ fn a_line_meets_the_wrap_from_its_top_to_its_baseline() { } } +#[test] +fn an_image_contour_stops_at_its_frame() { + let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); + let lid = d.default_layer(); + // An L-shaped frame: its right edge is at x = 150 down to y = 150, then at 250 below. + let pts = [(0.0, 50.0), (150.0, 50.0), (150.0, 150.0), (250.0, 150.0), (250.0, 200.0), (0.0, 200.0)].map(|(x, y)| Point::new(x, y)); + let frame = 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, frame); + // The image overhangs the frame on every side. + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(d.alloc()), + size: (300.0, 250.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: designcraft_doc::Fitting::None, + fit_align: 4, + crop: [0.0; 4], + }); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::RightSide; + it.wrap.contour = designcraft_doc::ContourType::BoundingBox; + it.wrap.offsets = [4.0; 4]; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let beside: Vec<&Line> = cs.frames[0].lines.iter().filter(|l| l.baseline - l.ascent > 54.0 && l.baseline < 146.0).collect(); + assert!(beside.len() >= 3, "{} lines beside the frame", beside.len()); + for l in beside { + assert!((l.x0 - 154.0).abs() < 0.5, "line at {} starts at {}", l.baseline, l.x0); + } + // Below the step the frame is wider. + let low = cs.frames[0].lines.iter().find(|l| l.baseline - l.ascent > 154.0 && l.baseline < 196.0).unwrap(); + assert!((low.x0 - 254.0).abs() < 0.5, "{}", low.x0); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result.