Effects: Outline Object on an image's fill or stroke follows its opaque pixels

An image's own fill or stroke with Outline Object now follows the outline of
the image's pixels that are at least 50% opaque, traced from the alpha and
fitted with curves, instead of the image's frame. An image without
transparency, one whose pixels can't be read, one of more than 64 megapixels
or whose decoded pixels take more than 256 MB, and one whose outline would
need more than 100,000 anchor points keep the frame. An image with no pixel
at least half opaque gives nothing to paint. Images of more than 4 megapixels
are traced from their alpha averaged down to 4 megapixels, and an averaged
pixel that covers a pixel at least half opaque stays at least half opaque, so
thin opaque lines stay in the outline. Each outline is traced once for its
pixels, kept for the next redraw while those pixels are loaded, and dropped
after them; a second thread that needs the same outline waits for the trace.

- vectorcraft-trace: `decode_alpha` reads an image's alpha, and
  `opaque_outline` traces it as Black and White mode at threshold 128.
- vectorcraft-effects: the reshaped image art uses it for rows whose first
  effect is Outline Object; Convert to Shape rows keep the frame.
- Docs: docs/mcp.md, the Outline Object params text and an evidence line in
  docs/target-app-parity.md. docs/mcp.md also says that Use Preview Bounds
  takes in the shapes an image's fills and strokes paint.
This commit is contained in:
dotCooCoo
2026-11-12 04:58:03 -08:00
parent 81247fdae9
commit 1d41b403e1
13 changed files with 875 additions and 24 deletions
Generated
+1
View File
@@ -6475,6 +6475,7 @@ dependencies = [
"vectorcraft-plugins",
"vectorcraft-text",
"vectorcraft-three-d",
"vectorcraft-trace",
]
[[package]]
+1
View File
@@ -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
+1 -1
View File
@@ -294,7 +294,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"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, or the image's frame when it has no transparency",
json!({}),
),
g(
+123 -16
View File
@@ -9,13 +9,14 @@
//! 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::HashSet;
use std::sync::Arc;
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
@@ -161,8 +162,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<AppearanceItem> {
items
.iter()
@@ -177,11 +178,98 @@ fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
.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")
}
/// 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<PathData>, (u32, u32))>;
/// One of [`Outlines`]' entries: the pixels, held weakly, and their outline once it is traced.
type Entry = (Weak<Vec<u8>>, Arc<OnceLock<Traced>>);
/// 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<usize, Entry>,
/// The lookups since the last pruning.
lookups: usize,
}
static OUTLINES: Mutex<Outlines> = 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<usize> = const { std::cell::Cell::new(0) };
}
/// Does [`OUTLINES`] keep an entry for `pixels`?
#[cfg(test)]
pub(crate) fn keeps_outline(pixels: &Weak<Vec<u8>>) -> 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<PathData> {
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![];
@@ -477,9 +565,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
@@ -556,15 +645,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<AppearanceItem>| {
(!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() };
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod imageoutline;
mod scale;
mod transformfx;
+318
View File
@@ -0,0 +1,318 @@
//! 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<u8> {
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<u8>, items: Vec<AppearanceItem>, 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<u8>)], rows: &[u8]) -> Vec<u8> {
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<Effect>) -> 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<Rect> {
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_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<Rect> {
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));
}
+116
View File
@@ -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<u8> {
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("<image") && svg.contains("stroke-width=\"8\""), "{svg}");
let back = vectorcraft_pdf::import(&vectorcraft_pdf::export(&doc, &Default::default()).unwrap()).unwrap();
let mut outlines = 0;
for l in &back.layers {
l.walk(&mut |n| {
let stroked = (n.appearance.stroke_width() - 8.0).abs() < 0.01;
outlines += usize::from(matches!(n.kind, NodeKind::Path { .. }) && stroked && n.geometric_bounds().is_some_and(disc));
});
}
assert_eq!(outlines, 1);
// Expand Appearance leaves the disc's outline below the image.
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, im);
let kids = n.children().unwrap();
assert!(kids.len() == 2 && matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
assert!(disc(kids[0].geometric_bounds().unwrap()), "{:?}", kids[0].geometric_bounds());
run(&mut s, "edit.undo", json!({}));
// An Offset Path of 3 pt after Outline Object moves the stroke 3 pt out: it shows from 63 to 70.
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 0}));
assert_eq!([shade(&s, 61, 100), shade(&s, 64, 100)], ["white", "black"]);
// Relinked to an opaque image, the stroke follows the frame 3 pt out: it shows from 43 to 50.
let dir = vectorcraft_testkit::temp_dir("outline-object-relink");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opaque.png");
let mut png = vec![];
image::RgbaImage::from_pixel(100, 100, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
std::fs::write(&path, png).unwrap();
run(&mut s, "links.relink", json!({"ids": [im.0], "path": path}));
assert_eq!([shade(&s, 41, 100), shade(&s, 45, 100), shade(&s, 64, 100)], ["white", "black", "red"]);
}
#[test]
fn an_images_outline_object_stroke_exports_selects_and_measures_along_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": 20, "color": "#000000"}));
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
// EPS and the PDF-compatible .ai draw the disc's outline stroked 20 pt, 20 pt inside the
// image's frame on each side (EPS moves the art to its bounding box).
let disc_outlines = |d: &vectorcraft_doc::Document| {
let mut frame = None;
d.walk(|c| {
if matches!(c.kind, NodeKind::Image(_)) {
frame = c.geometric_bounds();
}
});
let f = frame.unwrap();
let mut n = 0;
d.walk(|c| {
let disc = |b: vectorcraft_geom::Rect| {
[b.x0 - f.x0 - 20.0, b.y0 - f.y0 - 20.0, f.x1 - b.x1 - 20.0, f.y1 - b.y1 - 20.0].iter().all(|d| d.abs() < 0.6)
};
let stroked = (c.appearance.stroke_width() - 20.0).abs() < 0.01;
n += usize::from(matches!(c.kind, NodeKind::Path { .. }) && stroked && c.geometric_bounds().is_some_and(disc));
});
n
};
let saved = |s: &mut Session, p: Value| vectorcraft_format::base64_decode(run(s, "file.saveCopy", p)["dataBase64"].as_str().unwrap()).unwrap();
let eps = saved(&mut s, json!({"format": "eps", "previewFormat": "none", "thumbnails": false}));
assert_eq!(disc_outlines(&vectorcraft_eps::import_with(&eps, false).unwrap().document), 1);
let ai = saved(&mut s, json!({"format": "ai"}));
assert_eq!(disc_outlines(&crate::cmd::fileio::page_document(&ai, 0, &Default::default()).unwrap().0), 1);
// An Offset Path of 30 pt after Outline Object: the stroke runs round a circle of radius 60,
// from 30 to 50 on the left, where along the frame it would run from 10 to 30.
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 30}, "item": 0}));
assert_eq!((pixel(&s, 35, 100), pixel(&s, 15, 100)), ([0, 0, 0, 255], [255; 4]));
run(&mut s, "select.none", json!({}));
assert_eq!(click(&mut s, 35.0, 100.0), [im]);
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 15.0, 100.0).is_empty());
// Use Preview Bounds measures the stroke round the circle.
s.prefs.use_preview_bounds = true;
let b = s.transform_bounds(&[im]).unwrap();
assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 170.0, b.y1 - 170.0].iter().all(|d| d.abs() < 1.0), "{b:?}");
}
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
let v = ViewInfo::default();
+2
View File
@@ -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;
+292
View File
@@ -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<Option<(u32, u32, Vec<u8>)>, 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<u8> = 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<u8>) -> Result<Option<PathData>, 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, &params, 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<u8> {
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<u8>) {
(img.width, img.height, img.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect())
}
fn outline(img: &Raster) -> Result<Option<PathData>, 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<u8> {
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<u8> = (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());
}
}
+1 -1
View File
@@ -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<u64> {
pub(crate) fn gif_first_frame_pixels(bytes: &[u8]) -> Option<u64> {
if !bytes.starts_with(b"GIF8") {
return None;
}
+15 -6
View File
@@ -3443,12 +3443,21 @@ 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
`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.
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 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 while the image's pixels are loaded. 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 and the shapes its own fills and strokes paint.
```json
{"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}}
+1
View File
@@ -1370,6 +1370,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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.
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` (vectorcraft-engine).
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.
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.
Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case).
+3
View File
@@ -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 {