From ae1de74270e747cddeb60c112487edf3a6c72265 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:45:24 -0800 Subject: [PATCH] Text wrap: an Alpha Channel contour counts only inside the graphic's clipping path Opaque pixels outside the placed graphic's clipping path (inverted or not) are not drawn, and they no longer push text aside. An outline that only touches a clip's edge blocks nothing. --- crates/compose/src/lib.rs | 237 +++++++++++++++++++++++++----------- crates/compose/src/tests.rs | 56 +++++++++ 2 files changed, 223 insertions(+), 70 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ac563b616..76df87568 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -382,9 +382,36 @@ pub struct Contour { /// 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>, + /// The placed graphic's clipping path, for an outline traced from the graphic's pixels: the + /// outline counts only inside it too (empty: none). An inverted clipping path is filled + /// even-odd ([`designcraft_doc::Graphic::clip_region`]). + pub graphic_clip: Vec>, + pub graphic_clip_even_odd: bool, pub offset: f64, } +/// A filled shape: closed polylines and their fill rule (non-zero, or even-odd). +#[derive(Clone, Copy)] +struct Region<'a> { + rings: &'a [Vec], + even_odd: bool, +} + +impl Region<'_> { + fn nonzero(rings: &[Vec]) -> Region<'_> { + Region { rings, even_odd: false } + } + + /// Is a point with this winding number filled? + fn fills(self, winding: i32) -> bool { + if self.even_odd { winding % 2 != 0 } else { winding != 0 } + } + + fn edges(self) -> usize { + self.rings.iter().map(Vec::len).sum() + } +} + /// Points a contour outline may have; a larger outline wraps around its bounding box. pub const MAX_CONTOUR_POINTS: usize = 8192; @@ -396,7 +423,13 @@ 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 rings = flatten_rings(path, to_inner)?; - offset.is_finite().then_some(Contour { rings, clip: Vec::new(), offset: offset.max(0.0) }) + offset.is_finite().then_some(Contour { + rings, + clip: Vec::new(), + graphic_clip: Vec::new(), + graphic_clip_even_odd: false, + offset: offset.max(0.0), + }) } /// This outline counted only within `frame` (after `to_inner`); unclipped if the frame is @@ -408,18 +441,41 @@ impl Contour { } } + /// This outline counted only inside the graphic's clipping region `clip` (after `to_inner`), + /// filled even-odd when `even_odd` is set; unchanged if the region is empty or too detailed. + fn within_graphic_clip(self, clip: &designcraft_geom::BezPath, even_odd: bool, to_inner: designcraft_geom::Affine) -> Contour { + match flatten_rings(clip, to_inner) { + Some(graphic_clip) => Contour { graphic_clip, graphic_clip_even_odd: even_odd, ..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 } + Contour { + rings: map(self.rings), + clip: map(self.clip), + graphic_clip: map(self.graphic_clip), + graphic_clip_even_odd: self.graphic_clip_even_odd, + offset: self.offset, + } + } + + /// The clips the outline counts within. + fn clips(&self) -> impl Iterator> { + let frame = (!self.clip.is_empty()).then(|| Region::nonzero(&self.clip)); + let graphic = (!self.graphic_clip.is_empty()).then_some(Region { rings: &self.graphic_clip, even_odd: self.graphic_clip_even_odd }); + frame.into_iter().chain(graphic) } fn bounds(&self) -> Rect { - 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, - }; + let Some(mut r) = rings_bounds(&self.rings) else { return Rect::ZERO }; + for c in self.clips() { + if let Some(b) = rings_bounds(c.rings) { + r = r.intersect(b); + } + } r.inflate(self.offset, self.offset) } @@ -429,8 +485,8 @@ impl Contour { fn band_extent(&self, y0: f64, y1: f64) -> Option<(f64, f64)> { let (y0, y1) = (y0 - self.offset, y1 + self.offset); 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)?; + for c in self.clips() { + let (a, b) = rings_band_extent(c.rings, y0, y1)?; (lo, hi) = (lo.max(a), hi.min(b)); } (lo <= hi).then_some((lo - self.offset, hi + self.offset)) @@ -444,19 +500,21 @@ impl Contour { if !low.is_finite() || !high.is_finite() || high < low { return self.band_extent(y0, y1).into_iter().collect(); } - let mut spans = rings_band_spans(&self.rings, low, high); + let mut spans = rings_band_spans(Region::nonzero(&self.rings), low, high); // Extremely fragmented imported paths use the conservative outer extent, bounding the // work of subtracting intervals from every line. A traced 128-column mask stays below it. if spans.len() > 128 { return self.band_extent(y0, y1).into_iter().collect(); } - if !self.clip.is_empty() { - let clip = rings_band_spans(&self.clip, low, high); - // A fragmented frame, or shapes too detailed to compare within each line's budget, - // take the common part of the two projections, which can block more than the frame - // shows. - let exact = if clip.len() > 128 { None } else { clipped_band_spans(&self.rings, &self.clip, low, high) }; - spans = exact.unwrap_or_else(|| intersect_spans(&spans, &clip)); + let clips: Vec = self.clips().collect(); + if !clips.is_empty() { + let projected: Vec> = clips.iter().map(|c| rings_band_spans(*c, low, high)).collect(); + // A fragmented clip, or shapes too detailed to compare within each line's budget, + // take the common part of the projections, which can block more than the clips show. + let mut shapes = vec![Region::nonzero(&self.rings)]; + shapes.extend(&clips); + let exact = if projected.iter().any(|p| p.len() > 128) { None } else { clipped_band_spans(&shapes, low, high) }; + spans = exact.unwrap_or_else(|| projected.iter().fold(spans, |s, p| intersect_spans(&s, p))); } merge_spans(spans.into_iter().map(|(a, b)| (a - self.offset, b + self.offset)).collect()) } @@ -481,19 +539,22 @@ fn intersect_spans(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { out } -/// Occupied intervals across the band `y0..y1` of the part of `rings` inside `clip` (both filled -/// by the non-zero rule). A vertical line through a point of that part either crosses the middle -/// of the band inside both shapes, or stops inside the band on an edge of one shape that lies -/// inside the other. The spans are the middle slice of both shapes and the parts of each -/// shape's edges in the band that lie inside the other, so a part of the outline outside the -/// clip blocks nothing, even where it shares x positions with the clip in the same band. -/// None when that takes more than [`MAX_CLIPPED_WORK`] edge visits. -fn clipped_band_spans(rings: &[Vec], clip: &[Vec], y0: f64, y1: f64) -> Option> { +/// Occupied intervals across the band `y0..y1` of the part of the plane inside every one of +/// `shapes` (an outline and its clips). A vertical line through a point of that part either +/// crosses the middle of the band inside all the shapes, or stops inside the band on an edge of +/// one shape that lies inside the others. The spans are the middle slice of all the shapes and +/// the parts of each shape's edges in the band that lie inside the others, so a part of the +/// outline outside a clip blocks nothing, even where it shares x positions with the clip in the +/// same band. None when that takes more than [`MAX_CLIPPED_WORK`] edge visits. +fn clipped_band_spans(shapes: &[Region], y0: f64, y1: f64) -> Option> { let middle = y0 * 0.5 + y1 * 0.5; - let mut spans = intersect_spans(&merge_spans(middle_spans(rings, middle)), &merge_spans(middle_spans(clip, middle))); + let mut slices = shapes.iter().map(|s| merge_spans(middle_spans(*s, middle))); + let first = slices.next()?; + let mut spans = slices.fold(first, |a, b| intersect_spans(&a, &b)); let mut budget = MAX_CLIPPED_WORK; - edges_inside(rings, clip, y0, y1, &mut budget, &mut spans)?; - edges_inside(clip, rings, y0, y1, &mut budget, &mut spans)?; + for k in 0..shapes.len() { + edges_inside(shapes, k, y0, y1, &mut budget, &mut spans)?; + } Some(merge_spans(spans)) } @@ -503,10 +564,12 @@ fn spend(budget: &mut usize, n: usize) -> Option<()> { Some(()) } -/// Pushes to `out` the x-ranges of the parts of `rings`' edges within the band `y0..y1` that lie -/// inside `other` or on its boundary. None when `budget` runs out. -fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { - let n = other.iter().map(Vec::len).sum::().max(1); +/// Pushes to `out` the x-ranges of the parts of the edges of `shapes[k]` within the band `y0..y1` +/// that bound the part of the plane inside all of `shapes` ([`segment_inside`]). None when +/// `budget` runs out. +fn edges_inside(shapes: &[Region], k: usize, y0: f64, y1: f64, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { + let n = shapes.iter().map(|o| o.edges()).sum::().max(1); + let rings = shapes.get(k)?.rings; for ring in rings { let Some(&last) = ring.last() else { continue }; let mut a = last; @@ -520,7 +583,7 @@ fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, bu (u.min(v).clamp(0.0, 1.0), u.max(v).clamp(0.0, 1.0)) }; let (p, q) = (a + (b - a) * t0, a + (b - a) * t1); - segment_inside(p, q, other, n, budget, out)?; + segment_inside(p, q, shapes, k, n, budget, out)?; } a = b; } @@ -528,26 +591,41 @@ fn edges_inside(rings: &[Vec], other: &[Vec], y0: f64, y1: f64, bu Some(()) } -/// Pushes to `out` the x-ranges of the parts of segment `p`–`q` inside `rings` (`n` edges) or on -/// their boundary: the segment is split where it meets their edges, and each piece is tested at -/// its middle. Each pass over the edges takes `n` from `budget`; None when it runs out. -fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { +/// Pushes to `out` the x-ranges of the parts of segment `p`–`q`, an edge of `shapes[k]`, that +/// bound the part of the plane inside all of `shapes` (`n` edges in all). The segment is split +/// where it meets the other shapes' edges, and each piece is tested at its middle: it counts when +/// the points just beside it on one side are inside all the shapes. Where `shapes[k]` has no area +/// on either side (an open path), it counts when it is inside the other shapes or on their +/// boundary. Each pass over the edges takes `n` from `budget`; None when it runs out. +fn segment_inside(p: Point, q: Point, shapes: &[Region], k: usize, n: usize, budget: &mut usize, out: &mut Vec<(f64, f64)>) -> Option<()> { let r = q - p; let len2 = r.hypot2(); if !len2.is_finite() { return Some(()); } let at = |t: f64| p + r * t; + let in_others = |pt: Point| shapes.iter().enumerate().all(|(i, s)| i == k || inside_or_on(*s, pt)); spend(budget, n)?; if len2 < 1e-24 { - if inside_or_on(rings, p) { + if in_others(p) { out.push((p.x, p.x)); } return Some(()); } + // A step to either side of the segment, well above rounding at page coordinates. + let side = r.turn_90() * (1e-6 / len2.sqrt()); + let bounds_the_part = |m: Point| { + let own = shapes.get(k).copied(); + let in_own = |pt: Point| own.is_some_and(|s| inside_or_on(s, pt)); + let (a, b) = (m + side, m - side); + match (in_own(a), in_own(b)) { + (false, false) => in_others(m), + (ia, ib) => (ia && in_others(a)) || (ib && in_others(b)), + } + }; let (ylo, yhi) = (p.y.min(q.y), p.y.max(q.y)); let mut ts = vec![0.0, 1.0]; - for ring in rings { + for ring in shapes.iter().enumerate().filter(|(i, _)| *i != k).flat_map(|(_, s)| s.rings) { let Some(&last) = ring.last() else { continue }; let mut c = last; for &d in ring { @@ -576,8 +654,8 @@ fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &m ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12); for w in ts.windows(2) { let (u, v) = (w[0], w[1]); - spend(budget, n)?; - if inside_or_on(rings, at(u * 0.5 + v * 0.5)) { + spend(budget, 3 * n)?; + if bounds_the_part(at(u * 0.5 + v * 0.5)) { let (x0, x1) = (at(u).x, at(v).x); out.push((x0.min(x1), x0.max(x1))); } @@ -585,10 +663,10 @@ fn segment_inside(p: Point, q: Point, rings: &[Vec], n: usize, budget: &m Some(()) } -/// Is `pt` inside `rings` (non-zero rule) or within a hair of one of their edges? -fn inside_or_on(rings: &[Vec], pt: Point) -> bool { +/// Is `pt` inside `shape` or within a hair of one of its edges? +fn inside_or_on(shape: Region, pt: Point) -> bool { let mut winding = 0i32; - for ring in rings { + for ring in shape.rings { let Some(&last) = ring.last() else { continue }; let mut a = last; for &b in ring { @@ -607,7 +685,7 @@ fn inside_or_on(rings: &[Vec], pt: Point) -> bool { a = b; } } - winding != 0 + shape.fills(winding) } /// Sorted, overlapping spans joined. Zero-width spans (a vertical edge of an open path) are kept: @@ -626,9 +704,9 @@ fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { out } -fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { - let mut spans = middle_spans(rings, y0 * 0.5 + y1 * 0.5); - for ring in rings { +fn rings_band_spans(shape: Region, y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut spans = middle_spans(shape, y0 * 0.5 + y1 * 0.5); + for ring in shape.rings { let Some(&last) = ring.last() else { continue }; let mut a = last; for &b in ring { @@ -649,11 +727,11 @@ fn rings_band_spans(rings: &[Vec], y0: f64, y1: f64) -> Vec<(f64, f64)> { merge_spans(spans) } -/// The intervals of the line at height `middle` inside `rings` (non-zero rule), unsorted. -fn middle_spans(rings: &[Vec], middle: f64) -> Vec<(f64, f64)> { +/// The intervals of the line at height `middle` inside `shape`, unsorted. +fn middle_spans(shape: Region, middle: f64) -> Vec<(f64, f64)> { let mut spans = Vec::new(); let mut crossings = Vec::new(); - for ring in rings { + for ring in shape.rings { let Some(&last) = ring.last() else { continue }; let mut a = last; for &b in ring { @@ -671,7 +749,7 @@ fn middle_spans(rings: &[Vec], middle: f64) -> Vec<(f64, f64)> { let mut winding = 0i32; let mut previous = None; for (x, delta) in crossings { - if winding != 0 + if shape.fills(winding) && let Some(start) = previous { spans.push((start, x)); @@ -782,7 +860,7 @@ fn nothing_visible_wraps_nothing(doc: &Document, it: &Item, wrap: &designcraft_d fn wrap_exclusion( wrap: &designcraft_doc::TextWrap, b: Rect, - outline: impl FnOnce() -> Option<(designcraft_geom::BezPath, Option)>, + outline: impl FnOnce() -> Option, xf: Affine, inv: Affine, left_page: bool, @@ -802,24 +880,42 @@ fn wrap_exclusion( let contour = matches!(wrap.mode, WrapMode::BoundingBox | WrapMode::Contour).then_some(custom); return Exclusion { rect, mode: wrap.mode, side, contour, after: None }; } - let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|(path, frame)| { - let c = Contour::of(&path, inv * xf, o[0])?; - Some(match frame { - Some(frame) => c.within(&frame, inv * xf), - None => c, - }) + let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|src| { + let mut c = Contour::of(&src.path, inv * xf, o[0])?; + if let Some(frame) = &src.frame { + c = c.within(frame, inv * xf); + } + if let Some((clip, even_odd)) = &src.graphic_clip { + c = c.within_graphic_clip(clip, *even_odd, inv * xf); + } + 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 } } -/// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space, and -/// for an outline taken from the placed graphic, the frame path that clips it. Bounding Box -/// follows the part of the placed graphic the frame shows; Same As Clipping follows the graphic's -/// clipping path when it has one; Alpha Channel follows the image's traced outline when the -/// engine has traced one; detected edges and image paths follow the frame's path (as other -/// frames do). -fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> (designcraft_geom::BezPath, Option) { +/// The outline a contour wrap follows, in the frame's inner space, with what clips it. +struct Outline { + path: designcraft_geom::BezPath, + /// For an outline taken from the placed graphic: the frame path that clips it. + frame: Option, + /// For an outline traced from the graphic's pixels: the graphic's clipping region, and + /// whether it is filled even-odd. + graphic_clip: Option<(designcraft_geom::BezPath, bool)>, +} + +impl Outline { + fn clipped_by(path: designcraft_geom::BezPath, frame: designcraft_geom::BezPath) -> Outline { + Outline { path, frame: Some(frame), graphic_clip: None } + } +} + +/// The outline a contour wrap of `kind` around frame `it` follows. Bounding Box follows the part +/// of the placed graphic the frame shows; Same As Clipping follows the graphic's clipping path +/// when it has one; Alpha Channel follows the image's traced outline when the engine has traced +/// one, within the graphic's clipping path too; detected edges and image paths follow the frame's +/// path (as other frames do). +fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> Outline { let frame = designcraft_geom::corners::apply(&it.path, &it.corners); let kind = wrap.contour; if kind == designcraft_doc::ContourType::AlphaChannel @@ -833,19 +929,20 @@ fn contour_path(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap) -> if wrap.inside_edges { path.extend(o.holes.to_bezpath().iter()); } - return (g.xf * to_graphic * path, Some(frame)); + // The clipping path hides the pixels outside it. + return Outline { graphic_clip: g.clip_region(), ..Outline::clipped_by(g.xf * to_graphic * path, frame) }; } if kind == designcraft_doc::ContourType::SameAsClipping && let Some((path, _)) = it.graphic().and_then(|g| g.clip_region()) { - return (path, Some(frame)); + return Outline::clipped_by(path, frame); } if kind == designcraft_doc::ContourType::BoundingBox && let Some(r) = it.graphic().and_then(|g| g.shown_bounds(it.inner_bounds())) { - return (designcraft_geom::kurbo::Shape::to_path(&r, 0.1), Some(frame)); + return Outline::clipped_by(designcraft_geom::kurbo::Shape::to_path(&r, 0.1), frame); } - (frame, None) + Outline { path: frame, frame: None, graphic_clip: None } } /// One frame of the thread, ready for composition. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ab108b03b..e8fb2aac8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -573,6 +573,62 @@ fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() { assert!(!cs.is_overset()); } +/// 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. +#[test] +fn an_alpha_channel_contour_stops_at_the_clipping_path() { + use designcraft_geom::{PathData, Point, SubPath}; + let rect = |x0: f64, x1: f64| { + PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 200.0), Point::new(x0, 200.0)], true)) + }; + let lines_beside = |invert: bool| { + let (mut d, sid, _) = doc_with(&[LOREM; 4].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); + let lid = d.default_layer(); + let asset = designcraft_doc::AssetId(d.alloc()); + let mut it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 300.0, 300.0)), + ); + let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::DetectEdges, rect(0.0, 75.0)); + clip.invert = invert; + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset, + size: (150.0, 200.0), + xf: designcraft_geom::Affine::translate((150.0, 100.0)), + clip: Some(std::sync::Arc::new(clip)), + auto_fit: Default::default(), + fit_align: 0, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::LeftSide; + it.wrap.contour = designcraft_doc::ContourType::AlphaChannel; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d.alpha_outlines.push(std::sync::Arc::new(designcraft_doc::AlphaOutline { + asset, + threshold: designcraft_doc::DEFAULT_ALPHA_THRESHOLD, + width: 150, + height: 200, + outer: rect(75.0, 150.0), + holes: PathData::default(), + source: std::sync::Weak::new(), + serial: designcraft_doc::AlphaOutline::next_serial(), + })); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + cs.frames[0].lines.iter().filter(|l| l.baseline - l.ascent > 100.0 && l.baseline + l.descent < 300.0).map(|l| l.x1).collect::>() + }; + let shown_left = lines_beside(false); + assert!(!shown_left.is_empty() && shown_left.iter().all(|x| *x > 260.0), "nothing visible wraps nothing: {shown_left:?}"); + let shown_right = lines_beside(true); + assert!(!shown_right.is_empty() && shown_right.iter().all(|x| *x <= 225.0 + 1e-6), "the right half wraps: {shown_right:?}"); +} + /// 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]