diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 47798d7a3..f0451f938 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -386,7 +386,7 @@ pub struct Contour { } /// Points a contour outline may have; a larger outline wraps around its bounding box. -const MAX_CONTOUR_POINTS: usize = 8192; +pub const MAX_CONTOUR_POINTS: usize = 8192; impl Contour { /// The outline of `path` (after `to_inner`), flattened; None if it is empty or too detailed. @@ -587,7 +587,8 @@ pub fn exclusion(doc: &Document, other: &Item, xf: Affine, inv: Affine, left_pag let group = other.shape == designcraft_doc::Shape::Group; let b = if group { (xf * other.xf.inverse()).transform_rect_bbox(other.bounds()) } else { other.path.transformed(xf).bounds().unwrap_or(Rect::ZERO) }; - wrap_exclusion(&other.wrap, b, || (!group).then(|| contour_path(doc, other, &other.wrap)), xf, inv, left_page) + let e = wrap_exclusion(&other.wrap, b, || (!group).then(|| contour_path(doc, other, &other.wrap)), xf, inv, left_page); + nothing_visible_wraps_nothing(doc, other, &other.wrap, e) } /// The wrap exclusion of the graphic placed in frame `it` (arguments as for [`exclusion`]): around @@ -595,7 +596,26 @@ pub fn exclusion(doc: &Document, other: &Item, xf: Affine, inv: Affine, left_pag pub fn graphic_exclusion(doc: &Document, it: &Item, xf: Affine, inv: Affine, left_page: bool) -> Option { let g = it.graphic()?; let shown = g.shown_bounds(it.inner_bounds())?; - Some(wrap_exclusion(&g.wrap, xf.transform_rect_bbox(shown), || Some(contour_path(doc, it, &g.wrap)), xf, inv, left_page)) + let e = wrap_exclusion(&g.wrap, xf.transform_rect_bbox(shown), || Some(contour_path(doc, it, &g.wrap)), xf, inv, left_page); + Some(nothing_visible_wraps_nothing(doc, it, &g.wrap, e)) +} + +/// An Alpha Channel contour around an image with no pixel more opaque than the threshold leaves +/// the text alone (the exclusion's mode becomes None). +fn nothing_visible_wraps_nothing(doc: &Document, it: &Item, wrap: &designcraft_doc::TextWrap, mut e: Exclusion) -> Exclusion { + if wrap.mode == WrapMode::Contour + && wrap.contour == designcraft_doc::ContourType::AlphaChannel + && wrap.path.is_none() + && let Some(g) = it.graphic() + && let Some(o) = doc.alpha_outline(g.asset, wrap.alpha_threshold) + && o.width > 0 + && o.height > 0 + && o.outer.subpaths.is_empty() + { + e.mode = WrapMode::None; + e.contour = None; + } + e } /// The exclusion of `wrap` around an object with spread-space bounds `b`; `outline` gives its @@ -637,8 +657,9 @@ fn wrap_exclusion( /// 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; detected edges, alpha channels and image paths need the image, -/// so they follow the frame's path (as other frames do). +/// 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) { let frame = designcraft_geom::corners::apply(&it.path, &it.corners); let kind = wrap.contour; @@ -765,11 +786,14 @@ fn wrap_obstacles( } let xf = parent * it.xf; let left = || left_page(parent.transform_rect_bbox(it.bounds()).center().x); + // An exclusion whose mode came back None (nothing visible to wrap) is left out. if it.wrap.mode != WrapMode::None { - out.push(exclusion(doc, it, xf, inv, left())); + out.extend(Some(exclusion(doc, it, xf, inv, left())).filter(|e| e.mode != WrapMode::None)); } match &it.content { - Content::Graphic(g) if g.wrap.mode != WrapMode::None => out.extend(graphic_exclusion(doc, it, xf, inv, left())), + Content::Graphic(g) if g.wrap.mode != WrapMode::None => { + out.extend(graphic_exclusion(doc, it, xf, inv, left()).filter(|e| e.mode != WrapMode::None)) + } Content::Group { .. } => { for c in it.shown_children() { wrap_obstacles(doc, c, xf, inv, skip, left_page, depth + 1, out); diff --git a/crates/engine/src/cmd/alpha_wrap.rs b/crates/engine/src/cmd/alpha_wrap.rs index cd6a1d00b..b4009de1d 100644 --- a/crates/engine/src/cmd/alpha_wrap.rs +++ b/crates/engine/src/cmd/alpha_wrap.rs @@ -91,7 +91,12 @@ pub(crate) fn trace(data: &Arc>, asset: AssetId, threshold: u8) -> Optio mask } }; - if let Some((outer, holes)) = rings(&mask, cols as usize, rows as usize) { + // Composition follows an outline of at most MAX_CONTOUR_POINTS path elements (holes + // included, for Include Inside Edges): a ring of k points is k + 1 elements with its close. + let fits = |outer: &[Vec<(i32, i32)>], holes: &[Vec<(i32, i32)>]| { + outer.iter().chain(holes).map(|r| r.len() + 1).sum::() <= designcraft_compose::MAX_CONTOUR_POINTS + }; + if let Some((outer, holes)) = rings(&mask, cols as usize, rows as usize).filter(|(o, h)| fits(o, h)) { let (sx, sy) = (f64::from(w) / f64::from(cols), f64::from(h) / f64::from(rows)); let path = |rings: &[Vec<(i32, i32)>]| { let mut p = BezPath::new(); @@ -240,15 +245,44 @@ mod tests { /// A 64 × 64 PNG: visible where `on(x, y)`. fn png(on: impl Fn(usize, usize) -> bool) -> Vec { - let mut pixels = vec![0; 64 * 64 * 4]; - for y in 0..64 { - for x in 0..64 { - let i = (y * 64 + x) * 4; + png_sized(64, on) + } + + /// A `size` × `size` PNG: visible where `on(x, y)`. + fn png_sized(size: usize, on: impl Fn(usize, usize) -> bool) -> Vec { + let mut pixels = vec![0; size * size * 4]; + for y in 0..size { + for x in 0..size { + let i = (y * size + x) * 4; pixels[i] = 120; pixels[i + 3] = if on(x, y) { 255 } else { 0 }; } } - designcraft_render::Rendered { width: 64, height: 64, pixels }.to_png() + designcraft_render::Rendered { width: size as u32, height: size as u32, pixels }.to_png() + } + + /// An outline too detailed for composition (here 4,096 separate dots, 20,480 path elements + /// on the finest grid) is traced on a coarser grid until composition can follow it. + #[test] + fn a_fragmented_outline_is_traced_coarser_until_composition_can_follow_it() { + let dots = png_sized(256, |x, y| x % 4 == 0 && y % 4 == 0); + let o = trace(&Arc::new(dots), AssetId(1), 25).unwrap(); + let elements = o.outer.to_bezpath().elements().len() + o.holes.to_bezpath().elements().len(); + assert!(elements > 0 && elements <= designcraft_compose::MAX_CONTOUR_POINTS, "{elements} path elements"); + } + + /// An image with no pixel more opaque than the threshold leaves the text alone: with the + /// frame's contour the caption over it is overset, with Alpha Channel it is set. + #[test] + fn an_image_with_nothing_visible_wraps_no_text() { + let (mut s, image, story) = page(&png(|_, _| false), [150.0, 150.0, 178.0, 178.0]); + assert!(overset(&s, story), "the frame's contour covers the caption"); + s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story), "nothing visible: nothing wraps"); + // The same for the wrap set on the placed graphic. + s.execute("object.textWrap", &json!({"ids": [image], "mode": "none"})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [image], "content": true, "mode": "contour", "contour": "alphaChannel"})).unwrap(); + assert!(!overset(&s, story)); } #[test]