mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
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:
@@ -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,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ®ion.0)
|
||||
{
|
||||
self.draw_image_in(ctx, f, im, Some(®ion), 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, ®ion.0)
|
||||
{
|
||||
self.draw_image_in(ctx, f, im, Some(®ion));
|
||||
self.draw_image_in(ctx, f, im, Some(®ion), 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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user