Merge pull request #4434 from dotCooCoo/feat/envelope-image-options

Envelopes: Anti-Alias and Preserve Shape Using change how distorted images draw
This commit is contained in:
BFlat
2026-10-29 12:15:25 +07:00
committed by GitHub
16 changed files with 391 additions and 37 deletions
+17 -6
View File
@@ -1,8 +1,8 @@
use super::envelope::{EnvelopeOptions, expand_envelope};
use super::envelope::{EnvelopeOptions, PreserveShape, expand_envelope};
use super::mesh::GradientMesh;
use super::warp::map_nonlinear;
use super::{
Affine, Appearance, AppearanceItem, Arc, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2,
Affine, Appearance, AppearanceItem, Arc, Color, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2,
blend_expand,
};
@@ -157,8 +157,10 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its
/// edges (at most half a point) so neighbours overlap and no seams show.
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
/// seams show.
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
@@ -189,11 +191,20 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
if len > 1e-12 { *p + v * (grow / len) } else { *p }
})
.collect();
let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default());
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = !w.options.anti_alias;
if w.options.preserve_shape == PreserveShape::Transparency {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
continue;
}
let mut clip = Node::path(n.id, shape, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
}
}
+4
View File
@@ -23,6 +23,9 @@ pub struct NodeExtras {
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// An image drawn without smoothing: each pixel of the picture takes the color of the image
/// pixel nearest to it (an envelope's distorted image with Envelope Options › Anti-Alias off).
pub pixelated: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
@@ -56,6 +59,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
graph: None,
series_index: None,
type_outlines: false,
pixelated: false,
shaper: None,
graphic_style: None,
attrs: None,
+5
View File
@@ -30,6 +30,7 @@ impl Serialize for Node {
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
is_default(&e.pixelated),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
@@ -69,6 +70,7 @@ impl Serialize for Node {
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("pixelated", &e.pixelated);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
@@ -158,6 +160,8 @@ pub(super) struct NodeWire {
#[serde(default)]
type_outlines: bool,
#[serde(default)]
pixelated: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
@@ -189,6 +193,7 @@ impl From<NodeWire> for Node {
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
pixelated: w.pixelated,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
+109
View File
@@ -154,6 +154,115 @@ fn images_bend_as_a_raster_mesh_warp() {
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
}
/// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one.
fn two_color_image(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, sel| {
d.images.insert("two".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(40.0);
let im = vectorcraft_doc::ImageObject { key: "two".into(), width: 2, height: 2, xf, link: None, placement: Default::default(), pixels: None };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
/// The pixels well inside the image's frame (the clip edges left out) that are a mix of its red
/// and blue rather than one of them.
fn mixed_pixels(s: &Session) -> usize {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(110.0, 110.0, 170.0, 170.0), 2.0, true);
let mut mixed = 0;
for y in 0..img.height {
for x in 0..img.width {
let p = img.pixel(x, y);
mixed += (p[0] > 40 && p[2] > 40) as usize;
}
}
mixed
}
#[test]
fn anti_alias_off_draws_distorted_images_without_smoothing() {
let mut s = session();
let im = two_color_image(&mut s);
sel(&mut s, &[im]);
let e = arch(&mut s, 2.0, json!({}));
let smooth = mixed_pixels(&s);
assert!(smooth > 400, "with Anti-Alias on the colors blend where the pixels meet: {smooth}");
s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap();
let mut pieces = 0;
for n in evaluated(&s, e) {
n.walk(&mut |c| pieces += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize);
}
assert!(pieces >= 32, "{pieces} pixelated pieces");
let hard = mixed_pixels(&s);
assert!(hard * 50 < smooth, "with Anti-Alias off each pixel is red or blue: {hard} mixed of {smooth}");
// The SVG asks viewers not to smooth the pieces either.
let svg = |s: &mut Session| {
let b64 = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
String::from_utf8(vectorcraft_format::base64_decode(&b64).unwrap()).unwrap()
};
assert!(svg(&mut s).matches("image-rendering").count() >= 32);
s.execute("object.envelope.options", &json!({"antiAlias": true})).unwrap();
assert!(!svg(&mut s).contains("image-rendering"));
s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap();
// Expanded, the pieces keep it through a native save.
s.execute("object.envelope.expand", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
let mut kept = 0;
back.walk(|c| kept += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize);
assert_eq!(kept, pieces);
}
fn clip_groups_in(nodes: &[Node]) -> usize {
let mut n = 0;
for c in nodes {
c.walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize);
}
n
}
#[test]
fn transparency_masks_distorted_images_instead_of_clipping_them() {
let mut s = session();
let im = red_image(&mut s);
sel(&mut s, &[im]);
let e = arch(&mut s, 80.0, json!({}));
let region = Rect::new(80.0, 60.0, 160.0, 160.0);
let clipped = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true);
assert!(clip_groups_in(&evaluated(&s, e)) >= 32);
s.execute("object.envelope.options", &json!({"preserveShape": "transparency"})).unwrap();
let out = evaluated(&s, e);
assert_eq!(clip_groups_in(&out), 0, "no clipping masks");
let (mut images, mut masked) = (0, 0);
for n in &out {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Image(_)) {
images += 1;
masked += c.extras().mask.as_ref().is_some_and(|m| m.clip && !m.invert) as usize;
}
});
}
assert!(images >= 32 && masked == images, "{masked} of {images} pieces masked");
// It draws as the clipping masks did.
let alpha = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true);
let (mut sum, mut n) = (0u64, 0u64);
for y in 0..clipped.height {
for x in 0..clipped.width {
let (a, b) = (clipped.pixel(x, y), alpha.pixel(x, y));
sum += (0..4).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs() as u64).sum::<u64>();
n += 4;
}
}
assert!(sum < n, "mean difference {} per channel", sum as f64 / n as f64);
assert!(alpha.pixel(80, 74)[0] > 200 && alpha.pixel(80, 74)[1] < 60, "the bent image shows: {:?}", alpha.pixel(80, 74));
}
#[test]
fn symbols_bend_with_the_envelope() {
let mut s = session();
+21 -12
View File
@@ -4,17 +4,21 @@ use super::{BlendMode, Exporter, Image, NodeKind, Rect, Size, Surface};
impl Exporter<'_> {
/// Image `im` placed `size` points wide and high, resampled and compressed as the Compression
/// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images);
/// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the
/// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`].
fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64)) -> Option<Image> {
/// cached by key (and size when images are resampled). With `interpolate` (and the export's
/// own setting), viewers may smooth it. `None`, with [`UNDECODED`] among the warnings, for an
/// image that is missing, can't be decoded or is in [`Self::left_out`].
fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64), interpolate: bool) -> Option<Image> {
let key = im.key.as_str();
let c = self.compression;
let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None);
let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
let interpolate = interpolate && self.interpolate;
let mut cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
if !interpolate && self.interpolate {
cache.push_str("\u{0}pixelated");
}
if let Some(i) = self.images.get(&cache) {
return i.clone();
}
let interpolate = self.interpolate;
let doc = self.doc;
let out = self.out.clone();
let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| {
@@ -43,8 +47,8 @@ impl Exporter<'_> {
/// on the image's own pixel grid (an image's soft mask as it opens): the alpha is written as the
/// image's soft mask, as an RGBA image's is, rather than as a luminosity soft mask over it,
/// through which CoreGraphics draws CMYK images on black boxes (#2159). `None` for other
/// images and masks; cached like [`Self::load_image`].
pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask) -> Option<Image> {
/// images and masks; cached like [`Self::load_image`], `interpolate` as there.
pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask, interpolate: bool) -> Option<Image> {
let art = &m.art;
let NodeKind::Image(mi) = &art.kind else { return None };
let plain =
@@ -53,14 +57,18 @@ impl Exporter<'_> {
return None;
}
let size = placed_size(im);
let cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1);
let interpolate = interpolate && self.interpolate;
let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1);
if !interpolate && self.interpolate {
cache.push_str("\u{0}pixelated");
}
if let Some(i) = self.images.get(&cache) {
return i.clone();
}
let doc = self.doc;
let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| {
let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?;
let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha));
let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha));
if let (Some(w), Some(_)) = (r.warning, &r.image) {
self.warn(w);
}
@@ -70,13 +78,14 @@ impl Exporter<'_> {
img
}
/// Draw image `im`: `img` when given (its pixels already written), else as it is loaded.
pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option<Image>) {
/// Draw image `im`: `img` when given (its pixels already written), else as it is loaded; with
/// `pixelated` ([`vectorcraft_doc::NodeExtras::pixelated`]) viewers are asked not to smooth it.
pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option<Image>, pixelated: bool) {
// An image placed or sized beyond [`MAX_COORD`] is left out.
if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) {
return;
}
let Some(img) = img.or_else(|| self.load_image(im, placed_size(im))) else { return };
let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { return };
let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return };
s.push_transform(&xf(im.xf));
s.draw_image(img, size);
+2 -2
View File
@@ -137,7 +137,7 @@ impl Exporter<'_> {
}
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
let alpha = match (&n.kind, mask) {
(NodeKind::Image(im), Some(m)) => self.masked_image(im, m),
(NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated),
_ => None,
};
if let Some(m) = mask
@@ -182,7 +182,7 @@ impl Exporter<'_> {
self.shape(s, n, &bp, *rule, page);
}
NodeKind::Text(t) => self.text(s, n, t, page),
NodeKind::Image(im) => self.image(s, im, alpha),
NodeKind::Image(im) => self.image(s, im, alpha, n.extras().pixelated),
NodeKind::SymbolInstance { symbol, xf } => {
// Within the objects a frame may draw of symbol instances, as on screen: nested
// instances multiply.
+25
View File
@@ -617,6 +617,31 @@ fn image_roundtrip() {
assert_eq!(decoded.get_pixel(3, 0).0, [0, 0, 0, 255]);
}
/// Images are written with /Interpolate true, so viewers may smooth them; a pixelated image (an
/// envelope's with Anti-Alias off) is written without it.
#[test]
fn a_pixelated_image_asks_viewers_not_to_smooth_it() {
for pixelated in [false, true] {
let mut d = doc(200.0, 200.0);
d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png()));
let im = ImageObject {
key: "img1".into(),
width: 4,
height: 2,
xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(NodeId(0), NodeKind::Image(Box::new(im)));
n.extras_mut().pixelated = pixelated;
add(&mut d, n);
let pdf = uncompressed(&d);
assert_eq!(pdf.matches("/Width 4").count(), 1, "the image is written");
assert_eq!(pdf.matches("/Interpolate").count(), usize::from(!pixelated), "pixelated: {pixelated}");
}
}
/// A 4 × 2 red image encoded as `format`, cut short `keep` bytes past the first `tag` in it: its
/// header reads, and its pixel data can't be decoded.
fn cut_image(format: image::ImageFormat, tag: &[u8], keep: usize) -> Vec<u8> {
+32
View File
@@ -439,6 +439,38 @@ fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() {
assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask");
}
/// A pixelated CMYK image (an envelope's with Anti-Alias off) masked by a grey image of its grid
/// is written without /Interpolate, as one without a mask is.
#[test]
fn a_pixelated_cmyk_image_with_its_grid_mask_asks_viewers_not_to_smooth_it() {
for pixelated in [false, true] {
let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk);
d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, flat([0u8, 102, 255, 0], 4)).unwrap()).unwrap());
d.images.insert("alpha".into(), ImageBlob::new("image/png", png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap())));
let image = |key: &str| {
let im = ImageObject {
key: key.into(),
width: 2,
height: 2,
xf: Affine::scale(20.0),
link: None,
placement: Default::default(),
pixels: None,
};
NodeKind::Image(Box::new(im))
};
let mask = vectorcraft_doc::OpacityMask::new(Node::new(d.alloc_id(), image("alpha")), true);
let mut n = Node::new(d.alloc_id(), image("img")).with_mask(Some(Box::new(mask)));
n.extras_mut().pixelated = pixelated;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let out = export_with_report(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap().bytes;
let text = String::from_utf8_lossy(&out);
assert!(!text.contains("/S/Luminosity"), "the mask is the image's /SMask");
assert_eq!(text.contains("/Interpolate"), !pixelated, "pixelated: {pixelated}");
}
}
/// #2950: Press Quality converts to the document's profile with its numbers kept, so in a CMYK
/// document an RGB image comes out CMYK, as the press needs; with no conversion (the default
/// preset) it stays RGB, as before.
+54 -1
View File
@@ -1,4 +1,5 @@
use super::{Affine, Arc, BezPath, Ink, Node, Pixmap, Shape, images, ink, paint};
use vectorcraft_doc::{AppearanceItem, NodeKind};
/// `key`'s pixels in the shared image store; `touch` marks them used.
pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>> {
@@ -90,12 +91,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 {
+19 -4
View File
@@ -1,8 +1,8 @@
use super::{
Affine, Arc, BezPath, ClipEntry, ClipPaintEntry, Compose, Composite, Document, FillRule, Frame, GeomEntry, ISOLATION_DIM, Ink, MAX_GUIDE_DASHES,
Node, NodeId, NodeKind, Rect, RenderContext, RenderOptions, Renderer, Shape, SymbolBoxes, TraceView, blend_mode, brush_fx, effects, fx, group,
holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, single_plain_fill,
text_geom_snapped,
holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, shape_masked_image,
single_plain_fill, text_geom_snapped, white_shape_mask,
};
impl Renderer {
@@ -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).
+5 -1
View File
@@ -562,7 +562,11 @@ impl Writer<'_> {
NodeKind::Image(im) => {
let id = self.id_attr(n);
let m = self.matrix(self.xf * im.xf);
let a = self.attrs(&css::transparency(n));
let mut props = css::transparency(n);
if n.extras().pixelated {
props.push(("image-rendering", "pixelated".into()));
}
let a = self.attrs(&props);
if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) {
let shared = shared.clone();
self.line(&format!("<use{id} transform=\"{m}\"{a} xlink:href=\"#{shared}\"/>"));
+4 -1
View File
@@ -5134,7 +5134,10 @@ the options, `editing`), or with no envelope selected the options new envelopes
the current shape) switch its kind and keep its content. `object.envelope.options` sets `fidelity` and the options
(`antiAlias`, `preserveShape`: clippingMask or transparency, `distortAppearance`, `distortLinearGradients`,
`distortPatternFills`; the last two need `distortAppearance`); with no envelope selected it sets them for new
envelopes. New envelopes start with Distort Appearance on, as in the reference app; envelopes saved before these
envelopes. With `antiAlias` false, the envelope's distorted images are drawn without smoothing, SVG export writes
them with `image-rendering: pixelated` and PDF export writes them without `/Interpolate`. With `preserveShape`
`transparency`, each piece of a distorted image is the image with a white opacity mask in the piece's shape instead
of a clip group, after Expand too. New envelopes start with Distort Appearance on, as in the reference app; envelopes saved before these
options existed keep their old look (appearance applied after the distortion).
`object.envelope.release` gives back the content and the envelope's shape (a grey gradient mesh for warp and mesh
+1
View File
@@ -845,6 +845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756).
Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`.
Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)).
The four polynomial distributions are original approximations of the public description, not measured
numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html).