Envelopes: Anti-Alias and Preserve Shape Using change how distorted images draw

Envelope Options stored Anti-Alias and Preserve Shape Using, but nothing that
draws an image read them: a distorted image was always smoothed and always
cut into clip groups.

- With Anti-Alias off, each piece of a distorted image is marked pixelated
  (saved as `pixelated`). The renderer draws it with nearest-neighbor
  sampling and without mips, SVG export writes `image-rendering: pixelated`,
  and PDF export writes it without /Interpolate.
- With Preserve Shape Using Transparency, each piece is the image with a
  white opacity mask in the piece's shape instead of a clip group. The
  renderer paints an image whose mask is one plain white path straight into
  that path, as it does for an image in a clip group, when the path lies
  inside the image's frame.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-29 11:07:57 +07:00
committed by bflatastic
parent 4651e80973
commit ab69d8f6d9
15 changed files with 345 additions and 28 deletions
+53 -1
View File
@@ -90,12 +90,64 @@ pub(crate) fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &
if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) {
return false;
}
inside_frame(im, region, 1.0)
}
/// Is `region` inside image `im`'s frame (within `slack` pixels)?
fn inside_frame(im: &vectorcraft_doc::ImageObject, region: &BezPath, slack: f64) -> bool {
let det = im.xf.determinant();
if !(det.abs() > 1e-12 && det.is_finite()) {
return false;
}
let b = im.xf.inverse().transform_rect_bbox(region.bounding_box());
b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0
b.x0 >= -slack && b.y0 >= -slack && b.x1 <= im.width as f64 + slack && b.y1 <= im.height as f64 + slack
}
/// Can image node `n` (`im`), whose opacity mask is one plain white path with Clip on (see
/// [`white_shape_mask`]) shaped like `region`, be painted straight into the region: the mask shows
/// the image fully inside the shape and hides it outside, as a clipping path does. The region must
/// lie inside the image's frame, since outside the frame the mask shows nothing.
pub(crate) fn shape_masked_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool {
n.visible
&& n.opacity >= 1.0
&& n.blend == vectorcraft_color::BlendMode::Normal
&& !n.isolate
&& n.knockout == vectorcraft_doc::Knockout::Neutral
&& !n.knockout_shape
&& n.appearance.items.is_empty()
&& n.appearance.effects.is_empty()
&& inside_frame(im, region, 1e-6)
}
/// Is opacity mask `m` one path filled with opaque white and nothing else, with Clip on and
/// Invert off (each piece of an envelope's distorted image with Preserve Shape Using
/// Transparency)?
pub(crate) fn white_shape_mask(m: &vectorcraft_doc::OpacityMask) -> bool {
use vectorcraft_color::{BlendMode, Color, Paint};
let a = &m.art;
let shows = |p: &Paint| !matches!(p, Paint::None);
let white = |p: &Paint| matches!(p, Paint::Solid { color, tint, .. } if *color == Color::WHITE && *tint >= 1.0);
let mut fills = 0;
for i in &a.appearance.items {
match i {
AppearanceItem::Fill(f) if f.visible && shows(&f.paint) => {
if !(white(&f.paint) && f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty()) {
return false;
}
fills += 1;
}
AppearanceItem::Stroke(s) if s.visible && shows(&s.paint) => return false,
_ => {}
}
}
m.clip
&& !m.invert
&& !m.disabled
&& fills == 1
&& a.visible
&& a.has_default_transparency()
&& a.appearance.effects.is_empty()
&& matches!(a.kind, NodeKind::Path { guide: false, .. })
}
/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask
+1 -1
View File
@@ -1,4 +1,4 @@
use super::blobs::{blob_key, blob_pixmap, image_mip, lookup, mask_value, mip_level, plain_image};
use super::blobs::{blob_key, blob_pixmap, image_mip, lookup, mask_value, mip_level, plain_image, shape_masked_image, white_shape_mask};
use super::clock::now;
use super::frame::{
ClipEntry, ClipPaintEntry, Frame, FrameStats, GeomEntry, MAX_INLINE_DEPTH, MISSING_INLINE_LOGGED, PlacedEntry, PlacedKey, PtrMap, SliceEntry,
+8 -5
View File
@@ -6,25 +6,28 @@ use crate::images;
impl Renderer {
pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
self.draw_image_in(ctx, f, im, None);
self.draw_image_in(ctx, f, im, None, false);
}
/// Draw an image, or with `area` (a region in the document inside the image's frame, see
/// [`plain_image`]) the image painted into that region.
/// [`plain_image`]) the image painted into that region; with `pixelated`
/// ([`vectorcraft_doc::NodeExtras::pixelated`]) without smoothing.
pub(super) fn draw_image_in(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
im: &vectorcraft_doc::ImageObject,
area: Option<&(BezPath, FillRule)>,
pixelated: bool,
) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
// Outline mode draws the image's frame, and with Document Setup → Show Images in Outline
// Mode its pixels in greyscale under the frame.
let outline = f.opts.outline;
// With mips the full pixels are only looked at (not marked used) until they're drawn, and
// a mip the store holds is drawn without them: they may have been dropped (#2820).
let mips = f.opts.image_mips;
// a mip the store holds is drawn without them: they may have been dropped (#2820). A
// pixelated image is drawn from its full pixels: a mip averages them.
let mips = f.opts.image_mips && !pixelated;
let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64);
let shown = if outline && !f.doc.setup.outline_images {
None
@@ -39,7 +42,7 @@ impl Renderer {
if let Some(pm) = shown {
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
let quality = if f.opts.smooth_images && !pixelated { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
let sampler = peniko::ImageSampler { quality, ..Default::default() };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
match area {
+17 -2
View File
@@ -142,6 +142,21 @@ impl Renderer {
if self.skipped(f, a) {
return;
}
// An image masked by one white shape painted straight into the shape, as an image in a
// clip group is (envelopes with Preserve Shape Using Transparency cut distorted images into
// many such pieces).
if let Some(m) = a.extras().mask.as_deref()
&& !f.opts.outline
&& !self.is_shape(a)
&& let NodeKind::Image(im) = &a.kind
&& white_shape_mask(m)
&& let Some(region) = self.clip_of(&m.art)
&& shape_masked_image(a, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), a.extras().pixelated);
self.stats.drawn += 1;
return;
}
if let Some(m) = a.extras().mask.as_deref()
&& !m.disabled
&& !f.opts.outline
@@ -373,7 +388,7 @@ impl Renderer {
&& let NodeKind::Image(im) = &image.kind
&& plain_image(image, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region));
self.draw_image_in(ctx, f, im, Some(&region), image.extras().pixelated);
self.stats.drawn += 1;
return;
}
@@ -427,7 +442,7 @@ impl Renderer {
self.draw_shape(ctx, f, n, &bp, *rule);
}
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
NodeKind::Image(im) => self.draw_image(ctx, f, im),
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, n.extras().pixelated),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.charge_symbol(&sym.art)
+84 -4
View File
@@ -913,12 +913,92 @@ fn images_take_their_nearest_pixel_without_smoothing() {
let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
// How many pixels across the image are neither black nor white.
let greys = |smooth_images| {
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() });
let greys = |d: &Document, smooth_images| {
let r = Renderer::new().render(d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() });
(0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count()
};
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
assert_eq!(greys(false), 0, "nearest: black, then white");
assert!(greys(&d, true) >= 4, "smooth: a ramp from black to white");
assert_eq!(greys(&d, false), 0, "nearest: black, then white");
// A pixelated image (an envelope's with Anti-Alias off) takes its nearest pixel either way.
d.node_mut(id).unwrap().extras_mut().pixelated = true;
assert_eq!(greys(&d, true), 0, "pixelated: black, then white");
}
/// An image masked by one white shape with Clip on draws as an image clipped by that shape, with
/// nothing drawn offscreen; any other mask goes through the offscreen mask.
#[test]
fn an_image_masked_by_a_white_shape_draws_as_if_clipped() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask};
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let doc_masked = |mask_color: Color, shape: Rect| {
let mut d = Document::new(100.0, 100.0);
d.images.insert("red".into(), ImageBlob::new("image/png", png.clone()));
let id = d.alloc_id();
let im = ImageObject {
key: "red".into(),
width: 2,
height: 2,
xf: Affine::translate((10.0, 10.0)) * Affine::scale(40.0),
link: None,
placement: Default::default(),
pixels: None,
};
let shape = rect_node(&mut d, shape, Paint::solid(mask_color), Paint::None, 0.0);
let n = Node::new(id, NodeKind::Image(Box::new(im))).with_mask(Some(Box::new(OpacityMask::new(shape, true))));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
};
let doc = |mask_color: Color| doc_masked(mask_color, Rect::new(10.0, 10.0, 50.0, 90.0));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let draw = |d: &Document| {
let mut r = Renderer::new();
r.threads = 0;
OFFSCREEN_PIXELS.with(|n| n.set(0));
let img = r.render(d, 100, 100, Affine::IDENTITY, &opts);
(img, OFFSCREEN_PIXELS.with(|n| n.get()))
};
let (img, px) = draw(&doc(Color::WHITE));
assert_eq!(px, 0, "no offscreen mask");
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "inside the shape");
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "outside it");
let (img, px) = draw(&doc(Color::rgb(0.5, 0.5, 0.5)));
assert!(px > 0, "a grey mask is drawn offscreen");
assert!((100..160).contains(&img.pixel(30, 50)[1]), "at about half opacity: {:?}", img.pixel(30, 50));
// A white mask reaching past the image's frame shows nothing outside the frame.
let (img, _) = draw(&doc_masked(Color::WHITE, Rect::new(0.0, 10.0, 50.0, 90.0)));
assert_eq!(img.pixel(5, 50), [255, 255, 255, 255], "left of the frame");
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]);
// In isolation mode, outside the isolated object, it is dimmed like the art around it.
let mut d = doc(Color::WHITE);
let other = rect_node(&mut d, Rect::new(60.0, 60.0, 90.0, 90.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let other = d.insert(Some(d.layers[0].id), usize::MAX, other).unwrap();
let isolated = RenderOptions { isolated: Some(other), ..opts.clone() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &isolated);
assert!(img.pixel(30, 50)[1] > 60, "dimmed: {:?}", img.pixel(30, 50));
}
/// A pixelated image drawn reduced with `image_mips` on takes its nearest full pixel, not a
/// mip's average.
#[test]
fn a_pixelated_image_draws_reduced_without_mips() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let img = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba(if (x + y) % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(64.0, 64.0);
d.images.insert("check".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let im =
ImageObject { key: "check".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(id, NodeKind::Image(Box::new(im)));
n.extras_mut().pixelated = true;
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let on = RenderOptions { image_mips: true, ..Default::default() };
let r = Renderer::new().render(&d, 16, 16, Affine::scale(0.25), &on);
let mixed = (0..16).flat_map(|y| (0..16).map(move |x| (x, y))).filter(|(x, y)| r.pixel(*x, *y)[0] > 40 && r.pixel(*x, *y)[2] > 40).count();
assert_eq!(mixed, 0, "each pixel red or blue");
}
/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows).