diff --git a/Cargo.lock b/Cargo.lock index b42037f540..0a1689bff7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6476,6 +6476,7 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-text", "vectorcraft-three-d", + "vectorcraft-trace", ] [[package]] diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index d5821607ec..14f9a0d08f 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } vectorcraft-plugins = { workspace = true } +vectorcraft-trace = { workspace = true } image = { workspace = true } kurbo = { workspace = true } # Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 602e2d7fe9..52232cd54e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -296,7 +296,7 @@ pub fn effect_catalog() -> Vec { "path.outlineObject", "Outline Object", PATH, - "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque (VectorCraft's own cutoff), and nothing when no pixel is; it gives the image's frame when the image has no transparency, its pixels can't be read, or the image or its outline is too large to trace", json!({}), ), g( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89b41df9e7..fa06d00922 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, - has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, + has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 77977c24d1..2bda0aa88f 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -10,12 +10,13 @@ //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives -//! them ([`image_rows`]). +//! them ([`image_rows`]): Outline Object follows the outline of the image's opaque area +//! ([`opaque_area`]). -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::rc::Rc; -use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak}; use vectorcraft_doc::live::Hooks; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; @@ -287,8 +288,8 @@ pub fn gives_image_shape(effects: &[Effect]) -> bool { /// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each /// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes -/// its shape from the image's box, and Outline Object gives the image's frame. The others paint -/// nothing. +/// its shape from the image's box, and Outline Object gives the outline of the image's opaque +/// area ([`opaque_area`]), or its frame when it has no transparency. The others paint nothing. fn image_rows(items: &[AppearanceItem]) -> Vec { items .iter() @@ -303,11 +304,109 @@ fn image_rows(items: &[AppearanceItem]) -> Vec { .collect() } +/// Does `item`, one of [`image_rows`], start from the outline of the image's opaque area (is its +/// first effect Outline Object rather than Convert to Shape)? +fn follows_opaque_area(item: &AppearanceItem) -> bool { + item.effects().first().is_some_and(|e| e.id == "path.outlineObject") +} + +/// Is `n` an image with a fill or stroke of its own that follows the outline of the image's opaque +/// area ([`image_rows`], [`opaque_area`])? The shape that fill or stroke paints then changes with +/// the image's pixels in the document while `n` stays the same. +pub fn follows_image_pixels(n: &Node) -> bool { + matches!(n.kind, NodeKind::Image(_)) + && n.appearance + .items + .iter() + .any(|i| shapes_image(i) && i.effects().iter().find(|e| gives_shape(e)).is_some_and(|e| e.id == "path.outlineObject")) +} + +/// The outline of a set of pixels' opaque area, in the pixel space they decode to, with that size +/// ([`traced_opaque_area`]). `None` for pixels that have no transparency or could not be traced. +type Traced = Option<(Arc, (u32, u32))>; + +/// One of [`Outlines`]' entries: the pixels, held weakly, and their outline once it is traced. +type Entry = (Weak>, Arc>); + +/// The outlines [`opaque_area`] has traced, by the address of the pixels each was traced from. +struct Outlines { + /// Each outline with its pixels. An entry does not keep its pixels, and while it is kept no + /// other pixels can take their address. Entries whose pixels are gone are dropped before a + /// new outline is traced and every [`PRUNE_EVERY`] lookups. + kept: BTreeMap, + /// The lookups since the last pruning. + lookups: usize, +} + +static OUTLINES: Mutex = Mutex::new(Outlines { kept: BTreeMap::new(), lookups: 0 }); + +/// How many lookups [`OUTLINES`] takes between two prunings of its entries for pixels that are gone. +const PRUNE_EVERY: usize = 256; + +#[cfg(test)] +thread_local! { + /// The outlines [`traced_opaque_area`] has traced on this thread. + pub(crate) static TRACES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Does [`OUTLINES`] keep an entry for `pixels`? +#[cfg(test)] +pub(crate) fn keeps_outline(pixels: &Weak>) -> bool { + OUTLINES.lock().unwrap_or_else(PoisonError::into_inner).kept.values().any(|(w, _)| w.ptr_eq(pixels)) +} + +/// The outline of the opaque area of the image `im` in the document +/// ([`vectorcraft_trace::opaque_outline`]: its pixels that are at least half opaque, outlined with +/// fitted curves), its pixels taken from `im` itself or from the first of `docs` that holds them. +/// `None` when the image has no transparency, its pixels can't be read or are too large to read +/// ([`vectorcraft_trace::decode_alpha`]), or its outline would be too complex: Outline Object then +/// follows the image's frame. The outline is traced once for each set of pixels and kept while +/// those pixels are: a caller that asks while another thread traces the same pixels waits for that +/// trace. +pub(crate) fn opaque_area(docs: &[Option<&Document>], im: &ImageObject) -> Option { + let blob = im.pixels.as_deref().or_else(|| docs.iter().flatten().find_map(|d| d.images.get(&im.key)))?; + let bytes = &blob.bytes; + let cell = { + let mut o = OUTLINES.lock().unwrap_or_else(PoisonError::into_inner); + o.lookups += 1; + if o.lookups >= PRUNE_EVERY { + o.lookups = 0; + o.kept.retain(|_, (w, _)| w.strong_count() > 0); + } + let key = Arc::as_ptr(bytes).addr(); + match o.kept.get(&key) { + Some((_, cell)) => Arc::clone(cell), + None => { + o.kept.retain(|_, (w, _)| w.strong_count() > 0); + let cell = Arc::new(OnceLock::new()); + o.kept.insert(key, (Arc::downgrade(bytes), Arc::clone(&cell))); + cell + } + } + }; + // Traced without the lock: another image's outline need not wait for this one. + let (path, (w, h)) = cell.get_or_init(|| traced_opaque_area(bytes)).clone()?; + let to_image = Affine::scale_non_uniform(f64::from(im.width) / f64::from(w.max(1)), f64::from(im.height) / f64::from(h.max(1))); + Some(path.transformed(im.xf * to_image)) +} + +/// [`opaque_area`]'s outline of the encoded image `bytes`. An image whose format has no alpha is +/// not decoded. +fn traced_opaque_area(bytes: &[u8]) -> Traced { + #[cfg(test)] + TRACES.with(|t| t.set(t.get() + 1)); + let (w, h, alpha) = vectorcraft_trace::decode_alpha(bytes).ok()??; + let outline = vectorcraft_trace::opaque_outline(w, h, alpha).ok()??; + Some((Arc::new(outline), (w, h))) +} + /// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill -/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that -/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies -/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that -/// live-effect plug-ins painting their pieces name are looked up in `doc`. +/// rule and the fill or stroke that paints it: the paths (or compound paths) that [`reshape`] +/// makes along its frame or, for those whose first effect is Outline Object, along the outline of +/// its opaque area ([`opaque_area`]), with each fill's or stroke's geometry effects applied as the +/// renderer applies them. Empty for anything but an image with such fills or strokes. The swatches +/// and patterns that live-effect plug-ins painting their pieces name are looked up in `doc`, and +/// the image's pixels in `doc` when the image does not hold them. pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) { return vec![]; @@ -608,9 +707,10 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped /// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own -/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and -/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline -/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask +/// fills and strokes that paint a shape ([`image_rows`]) become paths below and above it, along +/// its frame or, from Outline Object, along the outline of its opaque area ([`opaque_area`]), +/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object +/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, /// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is @@ -697,15 +797,33 @@ fn as_art_opts( } Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true }) }; - // The fills and strokes that paint a shape, as paths along the frame below and above - // the image. + // The fills and strokes that paint a shape, as paths below and above the image: along + // the frame, or for those whose first effect is Outline Object along the outline of + // the image's opaque area (traced only when one of them needs it). A run of either + // kind is one path; a fill or stroke whose outline is empty (an image with no opaque + // pixel) paints nothing. let (below, above) = n.appearance.split_contents(); let (below, above) = (image_rows(below), image_rows(above)); if below.is_empty() && above.is_empty() { clipped() } else { + let opaque = std::cell::OnceCell::new(); let rows = |items: Vec| { - (!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() }))) + let mut out = vec![]; + let mut items = items.into_iter().peekable(); + while let Some(first) = items.next() { + let follows = follows_opaque_area(&first); + let mut run = vec![first]; + while let Some(next) = items.next_if(|i| follows_opaque_area(i) == follows) { + run.push(next); + } + let path = if follows { opaque.get_or_init(|| opaque_area(&[symbols, paint_doc], im)).as_ref() } else { None }; + let path = path.unwrap_or(&frame); + if !path.is_empty() { + out.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: run, ..Default::default() }))); + } + } + out }; // Without geometry effects, the image is not clipped. let image = if fx.is_empty() { image.clone() } else { clipped() }; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 9185801f6b..56f9d40633 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod imageoutline; mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/imageoutline.rs b/crates/effects/src/tests/imageoutline.rs new file mode 100644 index 0000000000..5ef3955254 --- /dev/null +++ b/crates/effects/src/tests/imageoutline.rs @@ -0,0 +1,338 @@ +//! Outline Object on an image's own fill or stroke: the outline of the image's opaque area, for +//! every pixel format with alpha, and the frame for one without. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, Rect}; + +use crate::*; + +fn fx(id: &str) -> Effect { + Effect { id: id.into(), params: json!({}), visible: true } +} + +/// `img` encoded as `format`. +fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out +} + +/// A 100×100 RGBA image, transparent but for an opaque disc of radius 30 in its middle. +fn disc() -> image::DynamicImage { + image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(100, 100, |x, y| { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }]) + })) +} + +/// A document holding the encoded image `bytes` (key "img") and an image object of 100×100 pixels +/// placed at (50, 50) with the appearance items `items`, the image's own row at `contents`. +/// → (document, the image's node). +fn image_doc(bytes: Vec, items: Vec, contents: usize) -> (Document, Node) { + let mut d = Document::new(200.0, 200.0); + d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(Box::new(ImageObject { + key: "img".into(), + width: 100, + height: 100, + xf: Affine::translate((50.0, 50.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + n.id = d.alloc_id(); + n.appearance.items = items; + n.appearance.set_contents_at(contents); + let l = d.layers[0].id; + d.insert(Some(l), 0, n.clone()).unwrap(); + (d, n) +} + +/// A PNG of `w × h` pixels with the color type `color` at `depth` bits, the chunks `extra` before +/// its pixels, and the filtered rows `rows` stored without compression. +fn png(w: u32, h: u32, color: u8, depth: u8, extra: &[(&[u8; 4], Vec)], rows: &[u8]) -> Vec { + fn crc(data: &[u8]) -> u32 { + let mut c = !0u32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |kind: &[u8; 4], data: &[u8]| { + let mut body = kind.to_vec(); + body.extend_from_slice(data); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&crc(&body).to_be_bytes()); + }; + let mut ihdr = [w.to_be_bytes(), h.to_be_bytes()].concat(); + ihdr.extend([depth, color, 0, 0, 0]); + chunk(b"IHDR", &ihdr); + for (kind, data) in extra { + chunk(kind, data); + } + // A zlib stream of stored blocks. + let mut z = vec![0x78, 0x01]; + let blocks: Vec<&[u8]> = if rows.is_empty() { vec![rows] } else { rows.chunks(65_535).collect() }; + for (i, block) in blocks.iter().enumerate() { + let len = u16::try_from(block.len()).unwrap(); + z.push(u8::from(i + 1 == blocks.len())); + z.extend(len.to_le_bytes()); + z.extend((!len).to_le_bytes()); + z.extend_from_slice(block); + } + let (mut a, mut b) = (1u32, 0u32); + for &byte in rows { + a = (a + u32::from(byte)) % 65_521; + b = (b + a) % 65_521; + } + z.extend(((b << 16) | a).to_be_bytes()); + chunk(b"IDAT", &z); + chunk(b"IEND", &[]); + out +} + +fn stroke(effects: Vec) -> AppearanceItem { + let mut s = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + s.effects = effects; + AppearanceItem::Stroke(s) +} + +/// The bounds of the paths the image's own fills and strokes paint, below it first. +fn row_bounds(d: &Document, n: &Node) -> Vec { + let art = reshape::reshape(Some(d), n, None).unwrap(); + let mut out = vec![]; + for c in art.children().unwrap() { + if matches!(c.kind, NodeKind::Path { .. }) { + out.push(c.geometric_bounds().unwrap()); + } + } + out +} + +fn near(r: Rect, want: [f64; 4], tol: f64) -> bool { + [r.x0 - want[0], r.y0 - want[1], r.x1 - want[2], r.y1 - want[3]].iter().all(|d| d.abs() < tol) +} + +#[test] +fn outline_object_follows_the_opaque_area_of_an_image_with_alpha() { + let gray_alpha = image::DynamicImage::ImageLumaA8(disc().to_luma_alpha8()); + let deep = image::DynamicImage::ImageRgba16(disc().to_rgba16()); + for (name, img) in [("rgba", disc()), ("gray and alpha", gray_alpha), ("16 bits a channel", deep)] { + let (d, n) = image_doc(encoded(img, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert_eq!(rows.len(), 1, "{name}"); + // The disc runs from 70 to 130; the frame from 50 to 150. + assert!(near(rows[0], [70.0, 70.0, 130.0, 130.0], 0.5), "{name}: {:?}", rows[0]); + } +} + +#[test] +fn outline_object_follows_the_frame_of_an_image_without_transparency() { + let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(100, 100, image::Rgb([0, 0, 255]))); + let solid = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 255, 200]))); + let formats = [ + ("jpeg", encoded(opaque.clone(), image::ImageFormat::Jpeg)), + ("rgb png", encoded(opaque, image::ImageFormat::Png)), + ("rgba png, every pixel more than half opaque", encoded(solid, image::ImageFormat::Png)), + ]; + for (name, bytes) in formats { + let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert!(rows.len() == 1 && near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{name}: {rows:?}"); + } + // Pixels that can't be read keep the frame too. + let (d, n) = image_doc(b"not an image".to_vec(), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(near(row_bounds(&d, &n)[0], [50.0, 50.0, 150.0, 150.0], 1e-9)); +} + +#[test] +fn convert_to_shape_keeps_the_frame_beside_outline_object() { + // Below the image: a fill with Convert to Shape, then a stroke with Outline Object; above it + // a stroke with Convert to Shape before Outline Object, which starts from the frame. + let rect = Effect { id: "convertToShape.rectangle".into(), params: json!({"relative": true, "extraW": 0, "extraH": 0}), visible: true }; + let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + fill.effects = vec![rect.clone()]; + let items = vec![AppearanceItem::Fill(fill), stroke(vec![fx("path.outlineObject")]), stroke(vec![rect, fx("path.outlineObject")])]; + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), items, 2); + let rows = row_bounds(&d, &n); + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}"); + assert!(near(rows[1], [70.0, 70.0, 130.0, 130.0], 0.5), "{rows:?}"); + assert!(near(rows[2], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}"); + // The image keeps its place between them. + let art = reshape::reshape(Some(&d), &n, None).unwrap(); + assert!(matches!(art.children().unwrap()[2].kind, NodeKind::Image(_))); +} + +#[test] +fn a_transparent_image_gives_outline_object_nothing_to_paint() { + let clear = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 0, 100]))); + let (d, n) = image_doc(encoded(clear, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(row_bounds(&d, &n).is_empty()); + let art = reshape::reshape(Some(&d), &n, None).unwrap(); + assert!(matches!(art.children().unwrap().as_slice(), [only] if matches!(only.kind, NodeKind::Image(_)))); +} + +#[test] +fn the_outline_follows_the_images_own_pixels_and_placement() { + // Pixels kept on the image itself (art made outside a document), placed at half size. + let (_, mut n) = image_doc(vec![], vec![stroke(vec![fx("path.outlineObject")])], 1); + if let NodeKind::Image(im) = &mut n.kind { + im.pixels = Some(Arc::new(ImageBlob::new("image/png", encoded(disc(), image::ImageFormat::Png)))); + im.xf = Affine::translate((10.0, 20.0)) * Affine::scale(0.5); + } + let art = reshape::reshape(None, &n, None).unwrap(); + let b = art.children().unwrap()[0].geometric_bounds().unwrap(); + // The disc runs from 20 to 80 pixels: half that, moved by (10, 20). + assert!(near(b, [20.0, 30.0, 50.0, 60.0], 0.3), "{b:?}"); +} + +#[test] +fn an_off_center_outline_turns_and_reflects_with_a_smaller_copy_of_the_pixels() { + // A 120×60 image whose opaque area is the rectangle from (12, 6) to (40, 24), its pixels a + // copy at half size (as a linked image's preview is), turned a quarter and reflected. + let half = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(60, 30, |x, y| { + image::Rgba([255, 0, 0, if (6..20).contains(&x) && (3..12).contains(&y) { 255 } else { 0 }]) + })); + let (d, mut n) = image_doc(encoded(half, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let xf = Affine::translate((100.0, 40.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(-1.0, 1.0); + if let NodeKind::Image(im) = &mut n.kind { + (im.width, im.height, im.xf) = (120, 60, xf); + } + let rows = row_bounds(&d, &n); + let want = xf.transform_rect_bbox(Rect::new(12.0, 6.0, 40.0, 24.0)); + // The rectangle runs from (76, 0) to (94, 28); a flip in either direction, or the copy's + // width and height swapped, would move it. + assert!(near(want, [76.0, 0.0, 94.0, 28.0], 1e-9), "{want:?}"); + assert!(rows.len() == 1 && near(rows[0], [want.x0, want.y0, want.x1, want.y1], 0.5), "{rows:?}"); +} + +#[test] +fn an_image_in_a_group_exports_with_its_outline() { + let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let l = d.layers[0].id; + d.remove(n.id).unwrap(); + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(n)])).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut paths = vec![]; + for layer in &baked.layers { + layer.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 { + paths.push(c.geometric_bounds().unwrap()); + } + }); + } + assert!(matches!(paths.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{paths:?}"); +} + +/// The bounds of the paths stroked 4 pt in `d` with its effects baked, as exports write it. +fn baked_strokes(d: &Document) -> Vec { + let baked = bake_document(d).unwrap(); + let mut paths = vec![]; + for layer in &baked.layers { + layer.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 { + paths.push(c.geometric_bounds().unwrap()); + } + }); + } + paths +} + +#[test] +fn an_image_under_a_clipping_mask_or_with_an_opacity_mask_exports_with_its_outline() { + let disc_outline = |paths: &[Rect]| matches!(paths, [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)); + // Under a clipping mask that covers the whole image. + let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let l = d.layers[0].id; + d.remove(n.id).unwrap(); + let clip = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default()); + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(n)], clip: true })).unwrap(); + let paths = baked_strokes(&d); + assert!(disc_outline(&paths), "{paths:?}"); + // With an opacity mask. + let (mut d, mut n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let mut white = Node::path(NodeId(999), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default()); + white.appearance.set_fill(Paint::solid(Color::WHITE)); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(998), vec![Arc::new(white)]), true))); + let id = n.id; + *d.node_mut(id).unwrap() = n; + let paths = baked_strokes(&d); + assert!(disc_outline(&paths), "{paths:?}"); +} + +#[test] +fn outline_object_on_a_fill_and_on_an_indexed_image_follows_the_opaque_area() { + let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + fill.effects = vec![fx("path.outlineObject")]; + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![AppearanceItem::Fill(fill)], 1); + let rows = row_bounds(&d, &n); + assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}"); + // A palette image whose first color is transparent (a tRNS chunk). + let mut rows = vec![]; + for y in 0..100 { + rows.push(0); + for x in 0..100 { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + rows.push(u8::from(r <= 30.0)); + } + } + let palette = png(100, 100, 3, 8, &[(b"PLTE", vec![0, 0, 0, 255, 0, 0]), (b"tRNS", vec![0, 255])], &rows); + let (d, n) = image_doc(palette, vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}"); +} + +#[test] +fn an_image_too_large_or_too_complex_to_outline_keeps_its_frame() { + // 72 megapixels at 8 bits a channel, and 42 at 16: refused before their pixels are read. + for (w, h, depth) in [(9000, 8000, 8), (7000, 6000, 16)] { + let bytes = png(w, h, 6, depth, &[], &[]); + assert!(matches!(vectorcraft_trace::decode_alpha(&bytes), Err(vectorcraft_trace::TraceError::TooLarge { .. })), "{w}×{h}"); + let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9)), "{w}×{h}"); + } + // A checkerboard of 2 px squares, whose outline would need more than 100,000 anchor points. + let checker = image::RgbaImage::from_fn(640, 640, |x, y| image::Rgba([0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }])); + let (d, n) = + image_doc(encoded(image::DynamicImage::ImageRgba8(checker), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9))); +} + +#[test] +fn an_outline_is_traced_once_for_its_pixels_and_dropped_with_them() { + let traces = || reshape::TRACES.with(std::cell::Cell::get); + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let NodeKind::Image(im) = &n.kind else { panic!("not an image") }; + let before = traces(); + let first = reshape::opaque_area(&[Some(&d)], im).unwrap(); + assert_eq!(traces(), before + 1); + for _ in 0..3 { + row_bounds(&d, &n); + } + assert_eq!(reshape::opaque_area(&[Some(&d)], im).unwrap(), first); + assert_eq!(traces(), before + 1); + let pixels = Arc::downgrade(&d.images.get(&im.key).unwrap().bytes); + assert!(reshape::keeps_outline(&pixels)); + // Once the document and its pixels are gone, the next trace drops their entry. + drop(d); + assert!(pixels.upgrade().is_none()); + let (other, _) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![], 1); + reshape::opaque_area(&[Some(&other)], im).unwrap(); + assert_eq!(traces(), before + 2); + assert!(!reshape::keeps_outline(&pixels)); +} diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs index dd32135c18..437f932724 100644 --- a/crates/engine/src/tests/containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -256,6 +256,122 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram assert!(kids[0].appearance.fill_paint().is_none()); } +/// A 100×100 PNG, transparent but for an opaque red disc of radius 30 in its middle. +fn disc_png() -> Vec { + let mut png = vec![]; + image::RgbaImage::from_fn(100, 100, |x, y| { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }]) + }) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + png +} + +#[test] +fn an_images_stroke_with_outline_object_follows_its_opaque_area() { + let mut s = session(); + // The frame runs from 50 to 150, the disc from 70 to 130. + let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]})); + let im = NodeId(r["ids"][0].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 8, "color": "#000000"})); + run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0})); + // Under the image, the stroke's outer 4 pt shows around the disc, from 66 to 70; nothing runs + // along the frame. + let shade = |s: &Session, x: u32, y: u32| match pixel(s, x, y) { + p if p == RED => "red", + [r, g, b, _] if r.max(g).max(b) < 40 => "black", + [r, g, b, _] if r.min(g).min(b) > 215 => "white", + p => panic!("({x}, {y}) is {p:?}"), + }; + assert_eq!([shade(&s, 64, 100), shade(&s, 67, 100), shade(&s, 100, 100), shade(&s, 100, 132)], ["white", "black", "red", "black"]); + assert_eq!([shade(&s, 49, 100), shade(&s, 52, 100)], ["white", "white"]); + // SVG and PDF hold the disc's outline stroked 8 pt. + let doc = s.doc().unwrap().doc.clone(); + let disc = |b: vectorcraft_geom::Rect| [b.x0 - 70.0, b.y0 - 70.0, b.x1 - 130.0, b.y1 - 130.0].iter().all(|d| d.abs() < 0.5); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert!(svg.contains(" Vec { let v = ViewInfo::default(); diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index 2592d2b64e..4f14cadfd1 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -54,13 +54,15 @@ pub(super) fn evaluates_live(n: &Node) -> bool { /// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), /// a symbol instance (made from its symbol's art), an object with a 3D effect or a color /// adjustment (shaded or recolored through the color settings), one with Vector Halftone (its -/// dots sampled from everything it draws), or one with a live-effect plug-in that paints its pieces -/// (drawn only while the swatches and patterns they name are in the document), in it or in the -/// opacity masks of the objects in it. +/// dots sampled from everything it draws), one with a live-effect plug-in that paints its pieces +/// (drawn only while the swatches and patterns they name are in the document), or an image with a +/// fill or stroke that follows the outline of its pixels ([`effects::follows_image_pixels`]: the +/// document's pixels for it can change), in it or in the opacity masks of the objects in it. pub(super) fn reads_outside(n: &Node) -> bool { let mut reads = false; n.walk(&mut |c| { reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + || effects::follows_image_pixels(c) || effects::has_revolve(c) || effects::has_solid(c) || effects::has_adjustment(c) diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 2b6610beb1..4d41e16af8 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -9,7 +9,8 @@ //! envelope around an instance of it). What the walk behind that key found is kept with the node //! and with the document's symbols and patterns as the walk read them ([`Walk`]). A later frame //! makes the key from what was kept while the node and the document's symbols and patterns stay -//! the same. A walk that found a placed file is not kept. +//! the same. A walk that found a placed file, or an image whose own fills or strokes follow its +//! pixels, is not kept. //! Gradient meshes are tessellated into many small solid-colour quads (vello //! has no mesh shading), with the density chosen from the on-screen patch size; each quad is //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams @@ -44,7 +45,8 @@ type Walk = (Weak, Walked, Arc); /// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`Found`]), a /// [`MeshEntry`] per mesh node and subdivision level, and a [`Walk`] per node whose key -/// [`Renderer::live_found`] found without finding a placed file. +/// [`Renderer::live_found`] found without finding a placed file or an image whose own fills or +/// strokes follow its pixels. pub(crate) struct LiveCache { expanded: FrameCache, Expanded, Found)>, meshes: FrameCache<(usize, usize), MeshEntry>, @@ -97,7 +99,8 @@ pub fn expand_live(n: &Node) -> Vec { /// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with /// its inputs, see [`Renderer::live_found`]) reads from the document, placed files, /// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of -/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font +/// the patterns, the reads of the placed files it shows and the document's pixels for the images +/// whose own fills or strokes follow them ([`hash_resources`]), the font /// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the /// plug-in revision ([`crate::effects::plugin_revision`]) and `names` (the hash of the document's /// swatch and pattern names, [`DocInputs`]) when it shows an object with a plug-in effect, since a @@ -105,13 +108,13 @@ pub fn expand_live(n: &Node) -> Vec { /// while the swatches and patterns they name are in the document, and the color settings (`cms`, /// their address). Art is hashed by the addresses of its `Arc`s, which copies of the document /// share: a copy of the document has the same key. It also returns weak references to the reads -/// found, and what the walk found ([`Walked`]) when it found no placed file. +/// found, and what the walk found ([`Walked`]) when it found no placed file and no such image. fn resources_key(doc: &Document, cms: usize, names: u64, n: &Node) -> (u64, Vec>, Option) { let mut h = DefaultHasher::new(); let mut shown = Shown::default(); hash_resources(doc, n, &mut shown, &mut h); let walked = Walked { hash: h.finish(), text: shown.text, plugins: shown.plugins }; - (walked.key(cms, names), shown.reads, (!shown.placed).then_some(walked)) + (walked.key(cms, names), shown.reads, (!shown.placed && !shown.pixels).then_some(walked)) } /// What [`hash_resources`] found in a node: the hash of what it walked, whether it found type or @@ -153,6 +156,10 @@ struct Shown { text: bool, /// Whether a placed file was found. placed: bool, + /// Whether an image whose own fills or strokes follow its pixels was found + /// ([`crate::effects::follows_image_pixels`]): the document's pixels for it can change while + /// the walk's symbols and patterns stay the same. + pixels: bool, /// Whether an object with a visible plug-in effect was found. plugins: bool, /// Weak references to the reads of the placed files found. @@ -163,9 +170,11 @@ struct Shown { /// tilings ([`crate::pattern::hash_tiling`]) of the patterns in the fills and strokes of its /// objects and of type's characters, the address of each symbol instance's art, and the address of /// the read of each placed file that [`vectorcraft_doc::PlacedDocument::art`] shows (none before -/// the file is read). It walks `n` and then, one at a time from a list, the opacity-mask, pattern -/// and symbol art it finds, each pattern and each symbol's art once ([`first_seen`]). `shown` -/// records the type, placed files, reads and plug-in effects found. +/// the file is read), and the address of the document's pixels for each image whose +/// own fills or strokes follow them ([`crate::effects::follows_image_pixels`]). It walks `n` and +/// then, one at a time from a list, the opacity-mask, pattern and symbol art it finds, each pattern +/// and each symbol's art once ([`first_seen`]). `shown` records the type, placed files, reads, +/// such images and plug-in effects found. fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut DefaultHasher) { let mut todo = vec![]; let mut next = Some(n); @@ -203,6 +212,12 @@ fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut shown.placed = true; } NodeKind::Text(_) => shown.text = true, + // The address of the pixels the document holds for the image, which Update Link + // replaces under the same key ([`DocInputs`] holds them weakly). + NodeKind::Image(im) if crate::effects::follows_image_pixels(c) => { + doc.images.get(&im.key).map(|b| Arc::as_ptr(&b.bytes) as usize).hash(h); + shown.pixels = true; + } _ => {} } if let Some(m) = c.extras().mask.as_deref() { @@ -442,7 +457,7 @@ impl Renderer { /// evaluated live objects, type, symbol art or colors shaded through the color settings. The /// key is made from the [`Walk`] kept for `n` while `doc`'s symbols and patterns are those it /// read; `n` is walked again when they changed, and in every frame when the walk found a - /// placed file. + /// placed file or an image whose own fills or strokes follow its pixels. pub(crate) fn live_found(&mut self, doc: &Document, n: &Arc) -> Found { let inputs = self.doc_inputs(doc); let at = Arc::as_ptr(n) as usize; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 7e4007c6ac..e8cf7a1745 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -771,6 +771,27 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() { assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255])); } +#[test] +fn an_images_outline_object_stroke_follows_new_pixels_under_the_same_key() { + // The opaque image's stroke runs along its frame, from 18 to 22. + let mut d = stroked_image_doc(vec![fx("path.outlineObject", json!({}))], false); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!((lum(img.pixel(19, 35)), img.pixel(26, 35)), (0, [255, 0, 0, 255])); + // New pixels under the same key, as Update Link stores them, with the node unchanged: an + // opaque disc of radius 8 pixels in the middle. The stroke follows its edge at 26.5. + let disc = image::RgbaImage::from_fn(30, 30, |x, y| { + let r = (f64::from(x) + 0.5 - 15.0).hypot(f64::from(y) + 0.5 - 15.0); + image::Rgba([255, 0, 0, if r <= 8.0 { 255 } else { 0 }]) + }); + let mut png = vec![]; + disc.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!((lum(img.pixel(19, 35)), lum(img.pixel(26, 35))), (WHITE, 0)); +} + /// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document. fn glowing_square(fill: Color, id: &str, params: Value) -> Document { let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 46c29f4e05..48baeccf83 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -51,6 +51,7 @@ mod gradient; mod group; mod logo; mod mosaic; +mod opaque; mod quantize; mod shapes; mod smooth; @@ -64,6 +65,7 @@ use vectorcraft_geom::PathData; pub use contour::{Component, Loop, trace_mask}; pub use gradient::TracedGradient; pub use mosaic::mosaic; +pub use opaque::{ALPHA_MAX_DECODE_BYTES, OUTLINE_MAX_ANCHORS, OUTLINE_MAX_PIXELS, decode_alpha, opaque_outline}; pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; pub use shapes::TracedShape; use subpixel::Crack; diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs new file mode 100644 index 0000000000..52e80a368d --- /dev/null +++ b/crates/trace/src/opaque.rs @@ -0,0 +1,292 @@ +//! The outline of an image's opaque area: what Outline Object on an image's own fill or stroke +//! follows. + +use vectorcraft_geom::{Affine, PathData}; + +use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels}; +use crate::{Mode, Raster, TraceError, TraceParams, trace_within}; + +/// The most pixels [`opaque_outline`] traces: a larger image is reduced to this many first, +/// aspect kept ([`reduced`]), and its outline is scaled back up to the image's size. +pub const OUTLINE_MAX_PIXELS: u64 = 4 << 20; + +/// The most anchor points an outline may have; an image whose outline would have more gives +/// [`TraceError::TooComplex`]. +pub const OUTLINE_MAX_ANCHORS: usize = 100_000; + +/// The most bytes [`decode_alpha`] decodes an image's samples into (256 MB: 64 megapixels at 8 +/// bits a channel, 32 megapixels at 16). +pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; + +/// The alpha of the encoded image `bytes` (PNG, WebP, GIF, TIFF and the other formats the image +/// reader takes), one byte a pixel, row by row, with the image's width and height. `None` when the +/// image's format or color type has no alpha; the image is then not decoded. An image of more +/// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], +/// gives [`TraceError::TooLarge`] before it is decoded. +pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceError> { + use image::ImageDecoder; + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let decoder = reader.into_decoder().map_err(decode_err)?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + if !decoder.color_type().has_alpha() { + return Ok(None); + } + let pixels = u64::from(w) * u64::from(h); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let alpha: Vec = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? { + image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), + img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + }; + Ok(Some((w, h, alpha))) +} + +/// The outline of the pixels of a `width × height` image whose `alpha` (one byte a pixel, row by +/// row) is at least half opaque (128 or more), in its pixel space (x right, y down; the image +/// spans `0..width × 0..height`). `None` when every pixel is: the outline is then the image's +/// frame. An image with no such pixel gives an empty outline. +/// +/// The alpha is traced as Image Trace's Black and White mode traces a gray at threshold 128, with +/// its Paths and Corners at their defaults and no area left out as noise: the edge runs where the +/// alpha crosses one half, to a fraction of a pixel along anti-aliased edges, and the outline is +/// fitted with curves. Outer outlines and holes run in opposite directions, and an island in a +/// hole runs as outer outlines do, so the outline fills the same with either fill rule. An image +/// of more than [`OUTLINE_MAX_PIXELS`] is traced at that size ([`reduced`]). +pub fn opaque_outline(width: u32, height: u32, alpha: Vec) -> Result, TraceError> { + let (w, h) = (width, height); + if (w as usize).checked_mul(h as usize).is_none_or(|n| n == 0 || n != alpha.len()) { + return Err(TraceError::Empty); + } + if alpha.iter().all(|&a| a >= 128) { + return Ok(None); + } + if alpha.iter().all(|&a| a < 128) { + return Ok(Some(PathData::default())); + } + let (nw, nh) = fit_size(w, h, OUTLINE_MAX_PIXELS); + let alpha = if (nw, nh) == (w, h) { alpha } else { reduced(&alpha, w, h, nw, nh) }; + // Opaque pixels are black, transparent ones white. + let mut rgba = Vec::with_capacity(alpha.len() * 4); + for a in alpha { + let g = 255 - a; + rgba.extend_from_slice(&[g, g, g, 255]); + } + let gray = Raster::new(nw, nh, rgba)?; + let params = TraceParams { mode: Mode::BlackAndWhite, threshold: 128, ignore_white: true, noise: 0, ..TraceParams::default() }; + let traced = trace_within(&gray, ¶ms, OUTLINE_MAX_ANCHORS)?; + let mut out = PathData::default(); + for p in traced.paths { + out.subpaths.extend(p.path.subpaths); + } + if (nw, nh) != (w, h) { + out = out.transformed(Affine::scale_non_uniform(f64::from(w) / f64::from(nw), f64::from(h) / f64::from(nh))); + } + Ok(Some(out)) +} + +/// The `w × h` `alpha` reduced to `nw × nh` (at most `w × h`): each pixel holds the mean of the +/// block of pixels it covers, and at least 128 when that block holds a pixel of 128 or more, so an +/// opaque line thinner than a block stays inside the outline. Along an edge the outline can then +/// run up to half a block further out than the full-size image's would. +fn reduced(alpha: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize); + let (mut sums, mut maxes) = (vec![0u64; cols.len()], vec![0u8; cols.len()]); + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill(0); + maxes.fill(0); + for y in rows.clone() { + let Some(row) = alpha.get(y * w as usize..(y + 1) * w as usize) else { break }; + for (x, c) in cols.iter().enumerate() { + for &a in row.get(c.clone()).unwrap_or_default() { + sums[x] += u64::from(a); + maxes[x] = maxes[x].max(a); + } + } + } + for (x, c) in cols.iter().enumerate() { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = u8::try_from((sums[x] + count / 2) / count).unwrap_or(u8::MAX); + out.push(if maxes[x] >= 128 { mean.max(128) } else { mean }); + } + } + out +} + +#[cfg(test)] +mod tests { + use kurbo::Shape; + + use super::*; + + /// The width, height and alpha of `img`, as [`opaque_outline`] takes them. + fn alpha(img: &Raster) -> (u32, u32, Vec) { + (img.width, img.height, img.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()) + } + + fn outline(img: &Raster) -> Result, TraceError> { + let (w, h, a) = alpha(img); + opaque_outline(w, h, a) + } + + /// A `w × h` raster, opaque red inside the disc of radius `r` around the middle and + /// transparent outside it. + fn disc(w: u32, h: u32, r: f64) -> Raster { + let (cx, cy) = (f64::from(w) / 2.0, f64::from(h) / 2.0); + Raster::from_fn(w, h, |x, y| { + let (dx, dy) = (f64::from(x) + 0.5 - cx, f64::from(y) + 0.5 - cy); + if dx.hypot(dy) <= r { [255, 0, 0, 255] } else { [0, 0, 0, 0] } + }) + } + + fn area(p: &PathData) -> f64 { + p.to_bezpath().area().abs() + } + + #[test] + fn the_outline_follows_the_opaque_disc_not_the_frame() { + let outline = outline(&disc(100, 100, 30.0)).unwrap().unwrap(); + let b = outline.bounds().unwrap(); + for (got, want) in [(b.x0, 20.0), (b.y0, 20.0), (b.x1, 80.0), (b.y1, 80.0)] { + assert!((got - want).abs() < 1.0, "{b:?}"); + } + let disc_area = std::f64::consts::PI * 30.0 * 30.0; + assert!((area(&outline) - disc_area).abs() < disc_area * 0.02, "{}", area(&outline)); + // Fitted with curves, not one line per pixel step. + assert!(outline.anchor_count() < 40, "{} anchors", outline.anchor_count()); + } + + #[test] + fn an_opaque_image_keeps_its_frame_and_a_transparent_one_has_no_outline() { + let opaque = Raster::from_fn(10, 10, |_, _| [0, 0, 255, 128]); + assert_eq!(outline(&opaque).unwrap(), None); + let clear = Raster::from_fn(10, 10, |_, _| [0, 0, 255, 127]); + assert!(outline(&clear).unwrap().unwrap().is_empty()); + // One pixel either way. + assert_eq!(outline(&Raster::from_fn(1, 1, |_, _| [9, 9, 9, 255])).unwrap(), None); + assert!(outline(&Raster::from_fn(1, 1, |_, _| [9, 9, 9, 0])).unwrap().unwrap().is_empty()); + // No pixels at all, or an alpha that doesn't match the size. + assert!(opaque_outline(0, 0, vec![]).is_err()); + assert!(opaque_outline(2, 2, vec![0]).is_err()); + } + + #[test] + fn pixels_less_than_half_opaque_are_outside() { + // A 20 px square, opaque, inside a 6 px band of alpha `band`, on a transparent 40 px image. + let framed = |band: u8| { + Raster::from_fn(40, 40, |x, y| { + let d = x.min(y).min(39 - x).min(39 - y); + let alpha = match d { + 10.. => 255, + 4.. => band, + _ => 0, + }; + [0, 0, 0, alpha] + }) + }; + let inner = outline(&framed(127)).unwrap().unwrap().bounds().unwrap(); + assert!((inner.x0 - 10.0).abs() < 0.5 && (inner.x1 - 30.0).abs() < 0.5, "{inner:?}"); + // A half-opaque band is inside; the edge runs through the middle of its outer pixels, + // which are half covered. + let outer = outline(&framed(128)).unwrap().unwrap().bounds().unwrap(); + assert!((outer.x0 - 4.5).abs() < 0.25 && (outer.x1 - 35.5).abs() < 0.25, "{outer:?}"); + let outer = outline(&framed(255)).unwrap().unwrap().bounds().unwrap(); + assert!((outer.x0 - 4.0).abs() < 0.25 && (outer.x1 - 36.0).abs() < 0.25, "{outer:?}"); + } + + #[test] + fn a_hole_in_the_opaque_area_is_a_hole_in_the_outline() { + let ring = Raster::from_fn(60, 60, |x, y| { + let d = (f64::from(x) + 0.5 - 30.0).hypot(f64::from(y) + 0.5 - 30.0); + [0, 0, 0, if (10.0..=25.0).contains(&d) { 255 } else { 0 }] + }); + let outline = outline(&ring).unwrap().unwrap(); + let want = std::f64::consts::PI * (25.0 * 25.0 - 10.0 * 10.0); + assert!((outline.to_bezpath().area().abs() - want).abs() < want * 0.03, "{}", outline.to_bezpath().area()); + assert!(!outline.to_bezpath().contains(kurbo::Point::new(30.0, 30.0))); + assert!(outline.to_bezpath().contains(kurbo::Point::new(30.0, 12.0))); + } + + #[test] + fn the_outline_fills_the_same_with_either_fill_rule() { + // A ring with an opaque island in its hole. + let target = Raster::from_fn(80, 80, |x, y| { + let d = (f64::from(x) + 0.5 - 40.0).hypot(f64::from(y) + 0.5 - 40.0); + [0, 0, 0, if (25.0..=35.0).contains(&d) || d <= 10.0 { 255 } else { 0 }] + }); + let path = outline(&target).unwrap().unwrap().to_bezpath(); + let winding = |x: f64, y: f64| path.winding(kurbo::Point::new(x, y)); + let (ring, hole, island, outside) = (winding(40.0, 10.0), winding(40.0, 20.0), winding(40.0, 40.0), winding(2.0, 2.0)); + // Non-zero and even-odd agree when every winding number is -1, 0 or 1. + assert!(ring.abs() == 1 && island == ring && hole == 0 && outside == 0, "{ring} {hole} {island} {outside}"); + } + + #[test] + fn a_large_image_is_traced_smaller_and_scaled_back() { + let big = disc(2400, 2000, 800.0); + assert!(u64::from(big.width) * u64::from(big.height) > OUTLINE_MAX_PIXELS); + let b = outline(&big).unwrap().unwrap().bounds().unwrap(); + for (got, want) in [(b.x0, 400.0), (b.y0, 200.0), (b.x1, 2000.0), (b.y1, 1800.0)] { + assert!((got - want).abs() < 3.0, "{b:?}"); + } + } + + #[test] + fn a_line_thinner_than_the_reduction_stays_in_a_large_images_outline() { + // 39 megapixels, reduced to 4: each traced pixel covers about 3 × 3. The only opaque + // pixels are a line 1 pixel wide, which averages to about a third opaque. + let (w, h) = (8192u32, 4800u32); + let mut a = vec![0u8; (w * h) as usize]; + for y in 1000..3000 { + a[(y * w + 2001) as usize] = 255; + } + let b = opaque_outline(w, h, a).unwrap().unwrap().bounds().unwrap(); + assert!(b.x0 > 1990.0 && b.x1 < 2012.0 && (b.y0 - 1000.0).abs() < 6.0 && (b.y1 - 3000.0).abs() < 6.0, "{b:?}"); + } + + #[test] + fn an_outline_of_too_many_anchors_is_refused() { + // A checkerboard of 2 px squares: more than 50,000 opaque squares. + let checker = Raster::from_fn(640, 640, |x, y| [0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }]); + assert!(matches!(outline(&checker), Err(TraceError::TooComplex { max: OUTLINE_MAX_ANCHORS }))); + } + + /// `img` encoded as `format`. + fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out + } + + #[test] + fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { + let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]); + let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px)); + let want: Vec = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect(); + for (name, img) in [ + ("rgba", rgba.clone()), + ("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())), + ("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())), + ] { + let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap(); + assert_eq!(got, Some((4, 4, want.clone())), "{name}"); + } + let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); + assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); + assert!(decode_alpha(b"not an image").is_err()); + } +} diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 3e61c58953..5e198e6fb7 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -59,7 +59,7 @@ pub struct Raster { /// The pixels the first frame of GIF `bytes` declares in its image descriptor, which the decoder /// allocates for before it crops the frame to the logical screen the dimension check reads. `None` /// when the bytes aren't a GIF or hold no frame header. -fn gif_first_frame_pixels(bytes: &[u8]) -> Option { +pub(crate) fn gif_first_frame_pixels(bytes: &[u8]) -> Option { if !bytes.starts_with(b"GIF8") { return None; } diff --git a/docs/mcp.md b/docs/mcp.md index 4519e80d90..f95db364e0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -770,7 +770,9 @@ leaves room for a legend label's stored indents, spacing and leading. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape -those outlines. On paths, compound paths and images it does not change what is drawn. +those outlines. On paths, compound paths and images it does not change what is drawn. On an image's own fill or stroke +it gives the outline of the image's opaque area, or the image's frame, as described under images' fills and strokes +below. Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with `isError: true` and a message. The model can read the message and retry. @@ -3757,12 +3759,24 @@ the fill or stroke paints, and SVG, PDF, EPS and the other vector exports write holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. An image's own fills and strokes draw only when they carry a visible Convert to Shape or Outline Object effect: Convert -to Shape sizes its shape from the image's box, Outline Object gives the image's frame, the effects after it reshape -that shape, and the effects before it are left out. Its Contents row stands for the image; `appearance.addFill` and +to Shape sizes its shape from the image's box, Outline Object gives the outline of the image's opaque area, the +effects after it reshape that shape, and the effects before it are left out. The opaque area is the pixels whose alpha +is at least 50%. Its outline is traced as Image Trace's Black and White mode traces at threshold 128 (Paths and +Corners at their defaults, no area left out as noise): the edge follows anti-aliased edges to a fraction of a pixel +and is fitted with curves. Outline Object gives the image's frame when the image has no transparency (a format +without alpha, or every pixel at least half opaque), when its pixels can't be read, when the outline would take more +than 100,000 anchor points, and when the image has more than 64 megapixels or its decoded pixels take more than 256 MB +(a 16-bit RGBA image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint. +An image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that +covers a pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in +the outline. The outline is traced the first time it is needed and kept for as long as the image's pixels are loaded. +After the pixels are gone, it is dropped when another outline is traced or within the next 256 outline lookups. When +the image's pixels change (`links.update` reading the linked file), the canvas draws the outline of the new pixels. +These are VectorCraft's own rules. The image's Contents row stands for the image; `appearance.addFill` and `appearance.addStroke` put a new item right below that row, so it paints under the image. SVG, PDF, EPS and the other vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the image. -Use Preview Bounds measures an image by its frame. +Use Preview Bounds measures an image by its frame and the shapes its own fills and strokes paint. A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a67f62acbd..daf1f0a8c1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1429,7 +1429,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). - An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. + An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box, or the outline of the image's opaque area or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds then measures an image by its frame. + Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 8a6f5f0256..3777f8fb81 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -85,6 +85,9 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ &["three-d", "effects"], // Inflate cuts its surface's grid cells to the shape with Pathfinder's booleans. &["pathops", "three-d"], + // Outline Object on an image's fill or stroke follows the outline Image Trace makes of its + // opaque area. + &["trace", "effects"], ]; fn intra_layer_allowed(from: &str, to: &str) -> bool {