Appearance: image fills and strokes draw the shape Convert to Shape or Outline Object gives them

Squashed from #2500 (its merges of main dropped).
This commit is contained in:
bflatastic
2026-10-20 02:06:54 +07:00
parent 926ca62952
commit a52198cefa
15 changed files with 447 additions and 66 deletions
+12 -10
View File
@@ -79,9 +79,10 @@ pub(crate) fn has_fx(n: &Node) -> bool {
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), is it a path
/// whose Transform effect copies it with a gradient ([`effects::has_gradient_copies`]), or is it a
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own
/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies
/// it with a gradient ([`effects::has_gradient_copies`]), or is it a group or layer with an
/// appearance of its own (see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|| effects::has_item_geometry(n)
@@ -341,9 +342,9 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
r
}
// As [`cull_bounds_in`] measures them, without the object's own raster effects: the
// reshaped art (type's outlines measured one by one with the effects of its own fills and
// strokes), grown by how far the effects in a symbol's art and a live object's content
// paint past it.
// reshaped art (type's outlines, and an image and the paths of its own fills and strokes,
// measured one by one with the effects of those fills and strokes), grown by how far the
// effects in a symbol's art and a live object's content paint past it.
_ if has_object_fx(n) => {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
@@ -351,7 +352,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
};
let reshaped = effects::reshape(n, symbol.as_deref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
}
Some(r) => r.visual_bounds_with(symbols),
@@ -444,8 +445,9 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
}
_ if has_fx(n) => visual_bounds(symbols, n),
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's
// outlines measured one by one with the geometry effects of its own fills and strokes),
// grown by how far the effects in a symbol instance's art paint past that art.
// outlines, and an image and the paths of its own fills and strokes, measured one by one
// with the geometry effects of those fills and strokes), grown by how far the effects in a
// symbol instance's art paint past that art.
_ if has_object_fx(n) => {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
@@ -453,7 +455,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
};
let reshaped = effects::reshape(n, symbol.as_deref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
Some(r) => r.visual_bounds_with(symbols),
+60 -1
View File
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, StrokeLayer, Symbol};
use vectorcraft_geom::{Point, shapes};
use super::*;
@@ -679,6 +679,65 @@ fn a_rectangle_on_a_fill_below_type_with_outline_object_fits_the_glyphs() {
assert_eq!(lum(img.pixel(x, glyphs.y1 as u32 + 12)), WHITE);
}
/// [`image_doc`] with a black 4 pt stroke of its own that carries the effects `row`, below the
/// image when `below`.
fn stroked_image_doc(row: Vec<Effect>, below: bool) -> Document {
let mut d = image_doc(vec![]);
let id = d.layers[0].children().unwrap()[0].id;
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
stroke.effects = row;
let ap = &mut d.node_mut(id).unwrap().appearance;
ap.items.push(AppearanceItem::Stroke(stroke));
ap.set_contents_at(usize::from(below));
d
}
#[test]
fn an_images_stroke_draws_only_the_shape_convert_to_shape_or_outline_object_gives_it() {
// The image's frame runs from 20 to 50. Without an effect the stroke draws nothing.
for below in [false, true] {
let img = render(&stroked_image_doc(vec![], below));
assert_eq!((lum(img.pixel(18, 35)), img.pixel(21, 35)), (WHITE, [255, 0, 0, 255]));
}
let rectangle = fx("convertToShape.rectangle", json!({"relative": true, "extraW": 0, "extraH": 0}));
let outline = fx("path.outlineObject", json!({}));
for effect in [rectangle, outline] {
// Below the image only the stroke's outer half shows, above it both halves.
let img = render(&stroked_image_doc(vec![effect.clone()], true));
assert_eq!((lum(img.pixel(17, 35)), lum(img.pixel(18, 35)), img.pixel(21, 35)), (WHITE, 0, [255, 0, 0, 255]), "{}", effect.id);
let img = render(&stroked_image_doc(vec![effect.clone()], false));
assert_eq!((lum(img.pixel(18, 35)), lum(img.pixel(21, 35)), img.pixel(23, 35)), (0, 0, [255, 0, 0, 255]), "{}", effect.id);
// An effect before it is left out.
let first = fx("path.offsetPath", json!({"offset": 10}));
let img = render(&stroked_image_doc(vec![first, effect.clone()], true));
assert_eq!((lum(img.pixel(10, 35)), lum(img.pixel(18, 35))), (WHITE, 0), "{}", effect.id);
}
// An Offset Path of 2 pt after Outline Object moves the stroke 2 pt out.
let img = render(&stroked_image_doc(vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 2}))], true));
assert_eq!((lum(img.pixel(15, 35)), lum(img.pixel(16, 35)), lum(img.pixel(19, 35))), (WHITE, 0, 0));
}
#[test]
fn an_images_stroke_is_drawn_when_only_it_is_in_view() {
// The stroke runs 8 pt outside the frame, from 10 to 14; the view shows x 15 and less.
let row = vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 8}))];
let d = stroked_image_doc(row, true);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::translate((85.0, 0.0)), &opts);
assert_eq!((lum(img.pixel(96, 35)), lum(img.pixel(99, 35))), (0, WHITE));
}
#[test]
fn rasterize_on_an_image_takes_in_its_strokes_shape() {
// The stroke runs 8 pt outside the frame, from 10 to 14; Rasterize at 72 ppi keeps it.
let row = vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 8}))];
let mut d = stroked_image_doc(row, true);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().appearance.effects.push(fx("rasterize", json!({"useDocumentResolution": false, "resolution": 72})));
let img = render(&d);
assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255]));
}
#[test]
fn a_strokes_own_glow_paints_around_that_stroke_only() {
let mut d = Document::new(100.0, 100.0);