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]